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
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111110" />
<meta name="theme-color" content="#faf8f5" />
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+1 -1
View File
@@ -100,7 +100,7 @@ const StorageConfigPage = lazy(() =>
import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
)
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
export default function App() {
return (
+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>
)
}
}
+108 -137
View File
@@ -1,162 +1,133 @@
@tailwind base;
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
html, body, #root { height: 100%; }
body {
background-color: #111110;
color: #e8e4d9;
background-color: #f9fafb; /* Premium Swiss Editorial Light/Slate Background */
color: #111827; /* Deep Graphite Charcoal Body Text */
margin: 0;
}
/* ─── SVG 噪点纹理 ─── */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/* ─── Scrollbar ─── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #2e2e2a;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: #3a3a35;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ─── Selection ─── */
::selection {
background: rgba(201, 149, 74, 0.3);
color: #e8e4d9;
background: rgba(201, 149, 74, 0.2);
color: #111827;
}
/* ─── Focus ring ─── */
/* ─── Focus Ring ─── */
*:focus-visible {
outline: 2px solid #c9954a;
outline-offset: 2px;
border-radius: 2px;
border-radius: 6px;
}
/* ─── Scrollbar ─── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: #c9954a; }
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar { display: none; }
/* ─── Reduced Motion ─── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@layer components {
/* ─── Surface Card — 替代 glass-panel ─── */
.surface-card {
@apply rounded-lg border border-cream-900/20 bg-surface-400 px-4 py-3;
/* No shadow, no blur. Pure material feel. */
/* ── Unified Swiss-Editorial Cards ── */
.card {
@apply rounded-2xl bg-white border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)];
}
.surface-card-elevated {
@apply surface-card;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
.card-hover {
@apply card transition-all duration-300 ease-out hover:border-gray-300 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)] hover:-translate-y-1;
}
.card-elevated {
@apply card shadow-[0_20px_40px_rgba(17,24,39,0.05),0_1px_3px_rgba(17,24,39,0.01)];
}
/* ─── 暖色分割线 ─── */
.warm-divider {
@apply border-t border-cream-900/15;
}
/* ─── 琥珀色按钮 — 替代 neon-button ─── */
.amber-btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-brand-500/30
bg-brand-500/10 px-4 py-2 text-sm font-medium text-brand-400 transition-all
hover:border-brand-500/50 hover:bg-brand-500/20 hover:text-brand-300
active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40;
}
.amber-btn--solid {
@apply amber-btn border-transparent bg-brand-600 text-cream-100
hover:bg-brand-500 hover:text-white;
}
.amber-btn--ghost {
@apply amber-btn border-transparent bg-transparent hover:bg-cream-900/10;
}
/* ─── 鼠尾草按钮 ─── */
.sage-btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-sage-400/20
bg-sage-400/10 px-4 py-2 text-sm font-medium text-sage-300 transition-all
hover:border-sage-400/40 hover:bg-sage-400/20 hover:text-sage-200
active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40;
}
/* ─── 表单输入 ─── */
.field-input {
@apply w-full rounded-lg border border-cream-900/20 bg-surface-600 px-4 py-2.5
text-sm text-cream-200 placeholder:text-cream-500/40
transition-colors
focus:border-brand-500/50 focus:bg-surface-500 focus:outline-none
disabled:cursor-not-allowed disabled:opacity-50;
}
.field-label {
@apply block text-xs font-medium uppercase tracking-wider text-cream-400 mb-1.5;
}
/* ─── 标签/徽章 ─── */
.badge {
@apply inline-flex items-center rounded-full px-2 py-0.5 text-2xs font-medium;
}
.badge--brand {
@apply badge bg-brand-500/15 text-brand-400;
}
.badge--sage {
@apply badge bg-sage-400/15 text-sage-300;
}
.badge--neutral {
@apply badge bg-cream-900/20 text-cream-400;
}
/* ─── 页面标题 ─── */
.page-heading {
@apply font-display text-2xl font-bold tracking-tight text-cream-100;
}
.page-subtitle {
@apply mt-1 text-sm text-cream-400;
}
/* ─── 表格(管理面板用) ─── */
.data-table {
@apply w-full text-left text-sm;
}
.data-table thead {
@apply border-b border-cream-900/20;
}
.data-table th {
@apply px-4 py-3 text-xs font-medium uppercase tracking-wider text-cream-400;
}
.data-table td {
@apply px-4 py-3 text-cream-300;
}
.data-table tbody tr {
@apply border-b border-cream-900/10 transition-colors hover:bg-cream-900/5;
}
/* ─── 旧版兼容别名(逐步废弃) ─── */
/* ── Glassmorphic Bright Panels ── */
.glass-panel {
@apply surface-card;
@apply backdrop-blur-xl bg-white/80 border border-white/50 shadow-[0_8px_32px_rgba(17,24,39,0.015)];
}
.neon-button {
@apply amber-btn;
.glass-card {
@apply rounded-2xl backdrop-blur-lg bg-white/90 border border-white/60 shadow-[0_4px_24px_rgba(17,24,39,0.01)];
}
.input-base {
@apply field-input;
.glass-card-elevated {
@apply rounded-2xl backdrop-blur-xl bg-white/95 border border-white/70 shadow-[0_16px_48px_rgba(17,24,39,0.03)];
}
}
/* ── Input Fields Redesign ── */
.input-field {
@apply w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-3 text-sm text-gray-900
placeholder:text-gray-400 outline-none transition-all duration-300 ease-out
focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/50;
}
.input-label {
@apply block mb-1.5 text-xs font-bold uppercase tracking-widest text-gray-500;
}
/* ── Premium Editorial Buttons Redesign ── */
.btn-primary {
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-5 py-3
text-sm font-bold text-white transition-all duration-300 ease-out shadow-sm
hover:bg-[#1f2937] hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98]
disabled:cursor-not-allowed disabled:opacity-40 disabled:translate-y-0;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5
text-sm font-semibold text-gray-600 transition-all duration-200 ease-out
hover:bg-gray-100 hover:text-gray-900 active:scale-[0.98];
}
.btn-outline {
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-gray-200/90
bg-white px-4 py-2.5 text-sm font-bold text-gray-700 transition-all duration-300 ease-out shadow-sm
hover:border-gray-300 hover:text-gray-900 hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98];
}
/* ── Badges Redesign ── */
.badge {
@apply inline-flex items-center rounded-full px-3 py-1 text-2xs font-bold uppercase tracking-wider;
}
.badge-brand { @apply badge bg-brand-50 text-brand-700 border border-brand-100/30; }
.badge-sage { @apply badge bg-sage-50 text-sage-700 border border-sage-100/30; }
.badge-neutral { @apply badge bg-gray-100 text-gray-600 border border-gray-200/50; }
/* ── Page Headings ── */
.page-heading { @apply font-display text-3xl font-extrabold tracking-tight text-gray-900; }
.page-subtitle { @apply mt-1 text-sm text-gray-500; }
/* ── Data Tables Redesign ── */
.data-table { @apply w-full text-left text-sm border-collapse; }
.data-table thead { @apply border-b border-gray-200 bg-gray-50/70; }
.data-table th { @apply px-5 py-4 text-xs font-bold uppercase tracking-wider text-gray-500; }
.data-table td { @apply px-5 py-4 text-gray-700 border-b border-gray-100; }
.data-table tbody tr { @apply transition-colors hover:bg-gray-50/50; }
/* ── Dividers ── */
.divider { @apply border-t border-gray-200/80; }
/* ── Backward Compatibility ── */
.surface-card { @apply card; }
.surface-card-elevated { @apply card-elevated; }
.field-input { @apply input-field; }
.field-label { @apply input-label; }
.amber-btn { @apply btn-outline; }
.amber-btn--solid { @apply btn-primary; }
.amber-btn--ghost { @apply btn-ghost; }
.neon-button { @apply btn-outline; }
.input-base { @apply input-field; }
.warm-divider { @apply divider; }
}
+21 -21
View File
@@ -86,21 +86,21 @@ export function AIAssistantPage() {
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-purple-500">
<Sparkles className="h-5 w-5 text-white" />
<Sparkles className="h-5 w-5 text-ink-600" />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">AI 助手</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">AI 助手</h1>
<p className="text-sm text-ink-50">
自然语言搜索 · 基于观影历史的智能推荐
</p>
</div>
</div>
{status && (
<div className="text-xs text-slate-400">
<div className="text-xs text-ink-50">
<span
className={
'mr-2 inline-block h-2 w-2 rounded-full ' +
(status.enabled ? 'bg-emerald-400' : 'bg-slate-500')
(status.enabled ? 'bg-emerald-400' : 'bg-sand-500/30')
}
/>
{status.enabled
@@ -112,7 +112,7 @@ export function AIAssistantPage() {
{/* Smart search */}
<section className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-white">智能搜索</h2>
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
<form onSubmit={onSearch} className="flex gap-2">
<input
className="input-base flex-1"
@@ -137,7 +137,7 @@ export function AIAssistantPage() {
onClick={() => {
setQuery(h)
}}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{h}
</button>
@@ -145,35 +145,35 @@ export function AIAssistantPage() {
</div>
{intent && (
<div className="rounded-lg border border-white/5 bg-white/5 p-3 text-xs text-slate-300">
<div className="mb-1 font-semibold text-slate-200">解析结果</div>
<div className="rounded-lg border border-white/5 bg-white/5 p-3 text-xs text-ink-100">
<div className="mb-1 font-semibold text-ink-200">解析结果</div>
<div className="flex flex-wrap gap-x-6 gap-y-1">
<span>
查询: <span className="text-primary-400">{intent.query || '—'}</span>
查询: <span className="text-brand-500">{intent.query || '—'}</span>
</span>
{intent.year !== undefined && intent.year > 0 && (
<span>
年份: <span className="text-primary-400">{intent.year}</span>
年份: <span className="text-brand-500">{intent.year}</span>
</span>
)}
{intent.genre && (
<span>
类型: <span className="text-primary-400">{intent.genre}</span>
类型: <span className="text-brand-500">{intent.genre}</span>
</span>
)}
{intent.type && (
<span>
分类: <span className="text-primary-400">{intent.type}</span>
分类: <span className="text-brand-500">{intent.type}</span>
</span>
)}
{intent.sort && (
<span>
排序: <span className="text-primary-400">{intent.sort}</span>
排序: <span className="text-brand-500">{intent.sort}</span>
</span>
)}
{intent.language && (
<span>
语言: <span className="text-primary-400">{intent.language}</span>
语言: <span className="text-brand-500">{intent.language}</span>
</span>
)}
</div>
@@ -192,7 +192,7 @@ export function AIAssistantPage() {
{/* Recommendations */}
<section className="glass-panel space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-semibold text-white">为你推荐</h2>
<h2 className="font-display text-lg font-semibold text-ink-600">为你推荐</h2>
<button onClick={onRecommend} disabled={recommending} className="neon-button">
{recommending ? (
<Loader2 size={16} className="animate-spin" />
@@ -202,7 +202,7 @@ export function AIAssistantPage() {
生成推荐
</button>
</div>
<p className="text-xs text-slate-500">
<p className="text-xs text-sand-500">
推荐基于你的最近观看历史。点击标题在媒体库中查找。
</p>
@@ -212,7 +212,7 @@ export function AIAssistantPage() {
<li key={i}>
<Link
to={`/search?q=${encodeURIComponent(t)}`}
className="flex items-center justify-between rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-sm text-slate-200 hover:border-primary-400/40 hover:text-primary-400"
className="flex items-center justify-between rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
>
<span className="truncate">{t}</span>
<Search size={14} className="shrink-0 opacity-60" />
@@ -223,7 +223,7 @@ export function AIAssistantPage() {
)}
{recs && recs.length === 0 && (
<p className="text-sm text-slate-400">
<p className="text-sm text-ink-50">
还没有推荐结果 — 先去看几部片子,我再给你挑。
</p>
)}
@@ -231,9 +231,9 @@ export function AIAssistantPage() {
{/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
{!status?.enabled && (
<p className="text-xs text-slate-500">
<p className="text-xs text-sand-500">
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
管理员可在 <Link to="/api-configs" className="text-primary-400">API 配置</Link>{' '}
管理员可在 <Link to="/api-configs" className="text-brand-500">API 配置</Link>{' '}
中接入 OpenAI / DeepSeek 等服务以获得更好效果。
</p>
)}
+13 -13
View File
@@ -25,17 +25,17 @@ export function APIConfigsPage() {
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<KeyRound className="h-6 w-6 text-primary-400" />
<KeyRound className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-white">外部 API 配置</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">外部 API 配置</h1>
<p className="text-sm text-ink-50">
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 的密钥。
后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。
</p>
</div>
</header>
{loading && <p className="text-slate-500">加载中…</p>}
{loading && <p className="text-sand-500">加载中…</p>}
<div className="space-y-3">
{items.map((item) => (
@@ -87,19 +87,19 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
return (
<form onSubmit={submit} className="glass-panel grid gap-3 md:grid-cols-[1fr_2fr]">
<div>
<p className="font-display text-lg font-semibold text-white">{item.provider}</p>
<p className="font-display text-lg font-semibold text-ink-600">{item.provider}</p>
{item.description && (
<p className="text-xs text-slate-400">{item.description}</p>
<p className="text-xs text-ink-50">{item.description}</p>
)}
<p className="mt-2 text-xs text-slate-500">
状态: {item.has_key ? <span className="text-emerald-400">已配置</span> : <span className="text-slate-500">未配置</span>}
<p className="mt-2 text-xs text-sand-500">
状态: {item.has_key ? <span className="text-emerald-400">已配置</span> : <span className="text-sand-500">未配置</span>}
{item.has_key && (
<span className="ml-2 font-mono text-primary-400">{item.masked_key}</span>
<span className="ml-2 font-mono text-brand-500">{item.masked_key}</span>
)}
</p>
</div>
<div className="space-y-2">
<label className="block text-xs text-slate-400">
<label className="block text-xs text-ink-50">
API Key (留空保留原值)
<input
className="input-base mt-1"
@@ -109,7 +109,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
onChange={(e) => setAPIKey(e.target.value)}
/>
</label>
<label className="block text-xs text-slate-400">
<label className="block text-xs text-ink-50">
Base URL (可选)
<input
className="input-base mt-1"
@@ -118,7 +118,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
onChange={(e) => setBaseURL(e.target.value)}
/>
</label>
<label className="inline-flex items-center gap-2 text-xs text-slate-400">
<label className="inline-flex items-center gap-2 text-xs text-ink-50">
<input
type="checkbox"
checked={enabled}
@@ -133,7 +133,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
<button
type="button"
onClick={testKey}
className="neon-button !text-xs !border-slate-400/40 !text-slate-300"
className="neon-button !text-xs !border-sand-400/40 !text-ink-100"
>
<Eye size={12} /> 状态
</button>
+12 -12
View File
@@ -16,7 +16,7 @@ export function AdminPage() {
]
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">管理后台</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
<div className="flex flex-wrap gap-2 border-b border-white/10">
{tabs.map((k) => (
<button
@@ -25,8 +25,8 @@ export function AdminPage() {
className={
'border-b-2 px-4 py-2 text-sm transition ' +
(tab === k.key
? 'border-primary-400 text-primary-400'
: 'border-transparent text-slate-400 hover:text-white')
? 'border-primary-400 text-brand-500'
: 'border-transparent text-ink-50 hover:text-white')
}
>
{k.label}
@@ -98,7 +98,7 @@ function LibraryPanel() {
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>路径</th>
@@ -109,12 +109,12 @@ function LibraryPanel() {
<tbody>
{libs.map((l) => (
<tr key={l.id} className="border-t border-white/5">
<td className="py-2 text-white">{l.name}</td>
<td className="text-slate-300">{l.path}</td>
<td className="text-slate-300">{l.type}</td>
<td className="py-2 text-ink-600">{l.name}</td>
<td className="text-ink-100">{l.path}</td>
<td className="text-ink-100">{l.type}</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
const r = await libraryAPI.scan(l.id)
toast.success(`扫描完成,新增 ${r.added}`)
@@ -153,7 +153,7 @@ function UsersPanel() {
return (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">用户名</th>
<th>角色</th>
@@ -164,9 +164,9 @@ function UsersPanel() {
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-white/5">
<td className="py-2 text-white">{u.username}</td>
<td className="text-slate-300">{u.role}</td>
<td className="text-slate-400">
<td className="py-2 text-ink-600">{u.username}</td>
<td className="text-ink-100">{u.role}</td>
<td className="text-ink-50">
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
</td>
<td className="py-2 text-right">
+9 -9
View File
@@ -115,13 +115,13 @@ export function AssistantChatPage() {
{/* Sessions sidebar */}
<aside className="glass-panel flex flex-col overflow-hidden">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-display text-sm font-semibold text-white">会话</h2>
<h2 className="font-display text-sm font-semibold text-ink-600">会话</h2>
<button onClick={onNew} className="neon-button !px-2 !py-1 text-xs">
<Plus size={12} /> 新建
</button>
</div>
{loading && (
<div className="flex justify-center py-6 text-slate-400">
<div className="flex justify-center py-6 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)}
@@ -132,8 +132,8 @@ export function AssistantChatPage() {
className={
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
(active?.session.id === s.id
? 'bg-primary-400/10 text-primary-400'
: 'text-slate-300 hover:bg-white/5 hover:text-white')
? 'bg-primary-400/10 text-brand-500'
: 'text-ink-100 hover:bg-white/5 hover:text-white')
}
onClick={() => onSelect(s.id)}
>
@@ -151,7 +151,7 @@ export function AssistantChatPage() {
</li>
))}
{!loading && sessions.length === 0 && (
<li className="px-2 py-2 text-xs text-slate-500">暂无会话</li>
<li className="px-2 py-2 text-xs text-sand-500">暂无会话</li>
)}
</ul>
</aside>
@@ -159,7 +159,7 @@ export function AssistantChatPage() {
{/* Conversation pane */}
<section className="glass-panel flex flex-col overflow-hidden">
{!active && (
<div className="m-auto text-center text-slate-400">
<div className="m-auto text-center text-ink-50">
<MessageSquare size={32} className="mx-auto mb-2 opacity-50" />
<p>选择或创建一个会话开始对话</p>
</div>
@@ -167,13 +167,13 @@ export function AssistantChatPage() {
{active && (
<>
<div className="mb-3 border-b border-white/5 pb-2">
<h2 className="font-display text-base font-semibold text-white">
<h2 className="font-display text-base font-semibold text-ink-600">
{active.session.title || '未命名'}
</h2>
</div>
<div className="flex-1 space-y-3 overflow-y-auto pr-2">
{active.messages.length === 0 && (
<p className="text-sm text-slate-500">说点什么开始对话…</p>
<p className="text-sm text-sand-500">说点什么开始对话…</p>
)}
{active.messages.map((m) => (
<Bubble key={m.id} message={m} />
@@ -211,7 +211,7 @@ function Bubble({ message }: { message: AssistantMessage }) {
? 'bg-primary-400/20 text-primary-100'
: message.role === 'system'
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
: 'bg-white/5 text-slate-200')
: 'bg-white/5 text-ink-200')
}
>
{message.content}
+15 -15
View File
@@ -65,11 +65,11 @@ export function DiscoverPage() {
{/* Header */}
<header className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-2xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 border border-primary-500/20">
<Sparkles className="h-8 w-8 text-primary-400" />
<Sparkles className="h-8 w-8 text-brand-500" />
</div>
<div>
<h1 className="font-display text-4xl font-bold text-white tracking-tight">发现</h1>
<p className="mt-1 text-base text-slate-400">
<h1 className="font-display text-4xl font-bold text-ink-600 tracking-tight">发现</h1>
<p className="mt-1 text-base text-ink-50">
来自 TMDb 的当日热门与流行榜单
</p>
</div>
@@ -83,13 +83,13 @@ export function DiscoverPage() {
<div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 text-amber-400" />
</div>
<h3 className="text-lg font-semibold text-white">TMDb API Key 未配置</h3>
<p className="text-sm text-slate-400 max-w-md mx-auto">
<h3 className="text-lg font-semibold text-ink-600">TMDb API Key 未配置</h3>
<p className="text-sm text-ink-50 max-w-md mx-auto">
您需要在管理后台填入 TMDb API Key 才能查看发现内容。
</p>
<Link
to="/admin"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors font-medium"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-500/20 text-brand-500 hover:bg-primary-500/30 transition-colors font-medium"
>
前往管理后台
<ExternalLink className="h-4 w-4" />
@@ -103,15 +103,15 @@ export function DiscoverPage() {
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
<Wifi className="h-8 w-8 text-orange-400" />
</div>
<h3 className="text-lg font-semibold text-white">无法连接到 TMDb</h3>
<p className="text-sm text-slate-400 max-w-lg mx-auto">
<h3 className="text-lg font-semibold text-ink-600">无法连接到 TMDb</h3>
<p className="text-sm text-ink-50 max-w-lg mx-auto">
服务器到 <code className="font-mono text-orange-300">api.themoviedb.org</code> 的连接超时。
通常是因为部署机器没有走代理。可以在系统环境变量里设置
<code className="font-mono text-orange-300 mx-1">HTTPS_PROXY</code>,
或在「外部 API」配置里填写自建反代地址(tmdb_api_proxy / tmdb_image_proxy)。
</p>
<details className="text-xs text-slate-500 max-w-lg mx-auto text-left">
<summary className="cursor-pointer hover:text-slate-400">查看原始错误</summary>
<details className="text-xs text-sand-500 max-w-lg mx-auto text-left">
<summary className="cursor-pointer hover:text-ink-50">查看原始错误</summary>
<pre className="mt-2 p-2 rounded bg-black/40 overflow-x-auto whitespace-pre-wrap">{anyErr}</pre>
</details>
</div>
@@ -134,7 +134,7 @@ export function DiscoverPage() {
{/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */}
{!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && (
<div className="text-center py-12">
<p className="text-slate-500">暂无发现内容</p>
<p className="text-sand-500">暂无发现内容</p>
</div>
)}
</div>
@@ -146,7 +146,7 @@ export function DiscoverPage() {
function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) {
return (
<section className="space-y-4">
<h2 className="font-display text-2xl font-semibold text-white pl-1">{title}</h2>
<h2 className="font-display text-2xl font-semibold text-ink-600 pl-1">{title}</h2>
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((item) => (
<DiscoverCard key={item.tmdb_id} item={item} />
@@ -169,7 +169,7 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600 text-xs">
<div className="flex h-full w-full items-center justify-center text-sand-400 text-xs">
无海报
</div>
)}
@@ -180,11 +180,11 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
)}
</div>
<div className="px-2.5 py-2 space-y-0.5">
<p className="text-xs font-medium text-white truncate group-hover:text-primary-400 transition-colors">
<p className="text-xs font-medium text-ink-600 truncate group-hover:text-brand-500 transition-colors">
{item.title}
</p>
{item.year > 0 && (
<p className="text-[11px] text-slate-500">{item.year}</p>
<p className="text-[11px] text-sand-500">{item.year}</p>
)}
</div>
</div>
+9 -9
View File
@@ -53,17 +53,17 @@ export function DlnaPage() {
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Cast className="h-6 w-6 text-primary-400" />
<Cast className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-white">DLNA 投屏</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">DLNA 投屏</h1>
<p className="text-sm text-ink-50">
扫描局域网中的 UPnP MediaRenderer 设备(电视、机顶盒等),选择媒体后一键播放。
</p>
</div>
</header>
<div className="glass-panel space-y-3">
<label className="block text-sm text-slate-300">选择媒体:</label>
<label className="block text-sm text-ink-100">选择媒体:</label>
<select
className="input-base"
value={selectedMedia}
@@ -79,7 +79,7 @@ export function DlnaPage() {
</div>
<div className="flex items-center justify-between">
<h2 className="font-display text-xl font-semibold text-white">
<h2 className="font-display text-xl font-semibold text-ink-600">
设备 ({devices.length})
</h2>
<button
@@ -93,7 +93,7 @@ export function DlnaPage() {
{devices.length === 0 && !scanning && (
<div className="glass-panel">
<p className="text-slate-300">
<p className="text-ink-100">
未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式,
目标设备已开启 DLNA / 屏幕镜像。
</p>
@@ -104,10 +104,10 @@ export function DlnaPage() {
{devices.map((dev) => (
<div key={dev.udn} className="glass-panel space-y-2 !p-4">
<div className="flex items-center gap-2">
<Tv size={18} className="text-primary-400" />
<p className="font-medium text-white">{dev.friendly_name || dev.model_name}</p>
<Tv size={18} className="text-brand-500" />
<p className="font-medium text-ink-600">{dev.friendly_name || dev.model_name}</p>
</div>
<p className="text-xs text-slate-400">
<p className="text-xs text-ink-50">
{dev.manufacturer} · {dev.ip_address}
</p>
<button
+16 -16
View File
@@ -66,8 +66,8 @@ export function DownloadClientsPage() {
<Server size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">下载器管理</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">下载器管理</h1>
<p className="text-sm text-ink-50">
qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
</p>
</div>
@@ -84,13 +84,13 @@ export function DownloadClientsPage() {
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)}
{!loading && clients.length === 0 && (
<div className="glass-panel py-12 text-center text-slate-400">暂无下载器</div>
<div className="glass-panel py-12 text-center text-ink-50">暂无下载器</div>
)}
{!loading && clients.length > 0 && (
@@ -102,22 +102,22 @@ export function DownloadClientsPage() {
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{c.name}</span>
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
<span className="font-medium text-ink-600">{c.name}</span>
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-ink-50">
{c.type}
</span>
{c.is_default && (
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
默认
</span>
)}
{!c.enabled && (
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">
<span className="rounded bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
已禁用
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-slate-400">
<div className="mt-1 truncate text-xs text-ink-50">
{c.host}
{c.username && ` · ${c.username}`}
</div>
@@ -125,7 +125,7 @@ export function DownloadClientsPage() {
<div className="flex shrink-0 gap-2">
<button
onClick={() => onTest(c.id)}
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Send size={12} className="inline" /> 测试
</button>
@@ -134,7 +134,7 @@ export function DownloadClientsPage() {
setEditing(c)
setShowForm(true)
}}
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
@@ -216,7 +216,7 @@ function ClientFormModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-white">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑下载器' : '添加下载器'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
@@ -278,7 +278,7 @@ function ClientFormModal({
</Field>
)}
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm text-slate-300">
<label className="flex items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
@@ -287,7 +287,7 @@ function ClientFormModal({
/>
设为默认
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<label className="flex items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
@@ -301,7 +301,7 @@ function ClientFormModal({
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
>
取消
</button>
@@ -318,7 +318,7 @@ function ClientFormModal({
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
+17 -17
View File
@@ -60,7 +60,7 @@ export function DownloadsPage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">下载</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">下载</h1>
<form onSubmit={onAdd} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<input
@@ -82,20 +82,20 @@ export function DownloadsPage() {
</form>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">实时状态</h2>
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">实时状态</h2>
{torrents === null && (
<p className="text-slate-500">
<p className="text-sand-500">
尚未连接到下载器 — 请到{' '}
<a href="/download-clients" className="text-primary-400 hover:underline">
<a href="/download-clients" className="text-brand-500 hover:underline">
下载器
</a>{' '}
页面添加并测试连接(qBittorrent / Aria2 / Transmission)。
</p>
)}
{torrents && torrents.length === 0 && <p className="text-slate-500">暂无运行中任务。</p>}
{torrents && torrents.length === 0 && <p className="text-sand-500">暂无运行中任务。</p>}
{torrents && torrents.length > 0 && (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>状态</th>
@@ -108,11 +108,11 @@ export function DownloadsPage() {
<tbody>
{torrents.map((t) => (
<tr key={t.hash} className="border-t border-white/5 align-top">
<td className="max-w-md truncate py-2 text-white" title={t.name}>
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
{t.name}
</td>
<td className="text-slate-300">{t.state}</td>
<td className="text-slate-300">
<td className="text-ink-100">{t.state}</td>
<td className="text-ink-100">
<div className="flex items-center gap-2">
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
<div
@@ -123,10 +123,10 @@ export function DownloadsPage() {
{(t.progress * 100).toFixed(1)}%
</div>
</td>
<td className="text-slate-300">
<td className="text-ink-100">
↓ {fmtSpeed(t.dlspeed)} / ↑ {fmtSpeed(t.upspeed)}
</td>
<td className="text-slate-300">{fmtBytes(t.size)}</td>
<td className="text-ink-100">{fmtBytes(t.size)}</td>
{role === 'admin' && (
<td className="py-2 text-right">
<button
@@ -151,9 +151,9 @@ export function DownloadsPage() {
{tasks.length > 0 && (
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">历史记录</h2>
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">历史记录</h2>
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">来源</th>
<th>URL</th>
@@ -164,12 +164,12 @@ export function DownloadsPage() {
<tbody>
{tasks.map((t) => (
<tr key={t.id} className="border-t border-white/5">
<td className="py-2 text-slate-300">{t.source}</td>
<td className="max-w-md truncate text-slate-300" title={t.url}>
<td className="py-2 text-ink-100">{t.source}</td>
<td className="max-w-md truncate text-ink-100" title={t.url}>
{t.url}
</td>
<td className="text-slate-300">{t.save_path || '—'}</td>
<td className="text-slate-500">
<td className="text-ink-100">{t.save_path || '—'}</td>
<td className="text-sand-500">
{new Date(t.created_at).toLocaleString()}
</td>
</tr>
+9 -9
View File
@@ -54,10 +54,10 @@ export function DuplicatesPage() {
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Copy className="h-6 w-6 text-primary-400" />
<Copy className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-white">重复文件</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">重复文件</h1>
<p className="text-sm text-ink-50">
通过稀疏采样 MD5(头部 / 中部 / 尾部各 1 MiB + 文件大小)检测重复媒体,
同一组中保留刮削过的较大文件作为主条目,其余标记为重复。
</p>
@@ -86,19 +86,19 @@ export function DuplicatesPage() {
</div>
{report && report.groups_found === 0 && (
<p className="text-slate-400">扫描了 {report.total_scanned} 项,未发现重复。</p>
<p className="text-ink-50">扫描了 {report.total_scanned} 项,未发现重复。</p>
)}
{report && report.groups.map((g) => (
<section key={g.hash} className="glass-panel space-y-2">
<div className="flex items-center justify-between">
<p className="font-mono text-xs text-slate-500">{g.hash}</p>
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
<span className="rounded border border-emerald-400/40 px-2 py-0.5 text-xs text-emerald-400">
主条目
</span>
</div>
<p className="font-medium text-white">{g.primary.title}</p>
<p className="font-mono text-xs text-slate-400">
<p className="font-medium text-ink-600">{g.primary.title}</p>
<p className="font-mono text-xs text-ink-50">
{g.primary.path} · {fmtBytes(g.primary.size_bytes)}
</p>
<div className="space-y-1 border-t border-white/5 pt-2">
@@ -106,8 +106,8 @@ export function DuplicatesPage() {
重复 ({g.duplicates.length})
</p>
{g.duplicates.map((d) => (
<div key={d.id} className="text-xs text-slate-400">
<span className="text-white">{d.title}</span> · {d.path} · {fmtBytes(d.size_bytes)}
<div key={d.id} className="text-xs text-ink-50">
<span className="font-medium text-ink-600">{d.title}</span> · {d.path} · {fmtBytes(d.size_bytes)}
</div>
))}
</div>
+4 -4
View File
@@ -28,10 +28,10 @@ export function FavouritesPage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">我的收藏</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">我的收藏</h1>
{loading && (
<div className="flex items-center gap-2 py-8 text-slate-400">
<div className="flex items-center gap-2 py-8 text-ink-50">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
加载中…
</div>
@@ -64,8 +64,8 @@ export function FavouritesPage() {
{isEmpty && (
<div className="glass-panel flex flex-col items-center gap-3 p-10 text-center">
<p className="text-lg text-slate-300">还没有任何收藏</p>
<p className="text-sm text-slate-500">
<p className="text-lg text-ink-100">还没有任何收藏</p>
<p className="text-sm text-sand-500">
点击媒体详情页的「收藏」按钮添加喜欢的内容
</p>
</div>
+14 -14
View File
@@ -49,8 +49,8 @@ export function FileManagerPage() {
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold text-white">文件浏览器</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">文件浏览器</h1>
<p className="text-sm text-ink-50">
只允许访问已配置的根目录(媒体库 + data + cache)。
</p>
</header>
@@ -72,13 +72,13 @@ export function FileManagerPage() {
</button>
)}
{data?.path && (
<span className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-xs text-slate-300">
<span className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-xs text-ink-100">
{data.path}
</span>
)}
</div>
{loading && <p className="text-slate-500">加载中…</p>}
{loading && <p className="text-sand-500">加载中…</p>}
{error && <div className="glass-panel !border-red-400/40 text-red-400">{error}</div>}
{!loading && data && !data.entries && data.roots && (
@@ -89,10 +89,10 @@ export function FileManagerPage() {
onClick={() => setPath(r.path)}
className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40"
>
<FolderOpen size={20} className="text-primary-400" />
<FolderOpen size={20} className="text-brand-500" />
<div>
<p className="font-mono text-sm text-white">{r.label}</p>
<p className="font-mono text-xs text-slate-400">{r.path}</p>
<p className="font-mono text-sm text-ink-600">{r.label}</p>
<p className="font-mono text-xs text-ink-50">{r.path}</p>
</div>
</button>
))}
@@ -102,7 +102,7 @@ export function FileManagerPage() {
{!loading && data?.entries && data.entries.length > 0 && (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>大小</th>
@@ -117,16 +117,16 @@ export function FileManagerPage() {
onClick={() => enter(e)}
title={e.path}
>
<td className="flex items-center gap-2 py-2 text-white">
<td className="flex items-center gap-2 py-2 text-ink-600">
{e.is_dir ? (
<Folder size={16} className="text-primary-400" />
<Folder size={16} className="text-brand-500" />
) : (
<FileVideo size={16} className="text-slate-400" />
<FileVideo size={16} className="text-ink-50" />
)}
{e.name}
</td>
<td className="text-slate-300">{e.is_dir ? '—' : fmtBytes(e.size)}</td>
<td className="text-slate-500">
<td className="text-ink-100">{e.is_dir ? '—' : fmtBytes(e.size)}</td>
<td className="text-sand-500">
{new Date(e.modified * 1000).toLocaleString()}
</td>
</tr>
@@ -137,7 +137,7 @@ export function FileManagerPage() {
)}
{!loading && data?.entries && data.entries.length === 0 && (
<p className="text-slate-400">空目录。</p>
<p className="text-ink-50">空目录。</p>
)}
</div>
)
+227 -278
View File
@@ -1,44 +1,23 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
ArrowRight,
Play,
Clock,
Film,
Compass,
Sparkles,
Layers,
Library as LibraryIcon,
Tv,
Music,
PlayCircle,
ArrowRight, Play, Clock, Film, Sparkles,
Library as LibraryIcon, Tv, Music, PlayCircle, ChevronRight
} from 'lucide-react'
import { libraryAPI, mediaAPI } from '../api/library'
import { playbackAPI, type HistoryItem } from '../api/playback'
import { imageURL } from '../api/client'
import { MediaCard } from '../components/MediaCard'
import type { Library, Media } from '../types'
import { groupSeries } from '../utils/groupSeries'
// LibraryRow 把单个媒体库的最近添加聚合成代表卡片列表。
type LibraryRow = {
library: Library
cards: ReturnType<typeof groupSeries>
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '电视剧',
anime: '动漫',
music: '音乐',
}
type LibraryRow = { library: Library; cards: ReturnType<typeof groupSeries> }
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={16} />,
tv: <Tv size={16} />,
anime: <PlayCircle size={16} />,
music: <Music size={16} />,
movie: <Film size={18} />, tv: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
}
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', anime: '动漫', music: '音乐' }
export function HomePage() {
const [libraries, setLibraries] = useState<Library[]>([])
@@ -48,11 +27,9 @@ export function HomePage() {
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
try {
// 1) 拉所有库 + 历史
const [libs, hist] = await Promise.all([
libraryAPI.list().catch(() => [] as Library[]),
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
@@ -60,295 +37,267 @@ export function HomePage() {
if (cancelled) return
setLibraries(libs)
setHistory(hist.filter((h) => !h.completed && !!h.media))
// 2) 并行为每个库拉最近 60 条 media,前端折叠成剧集卡片
const perLib = await Promise.all(
libs.map(async (lib) => {
try {
const page = await libraryAPI.listMedia(lib.id, 1, 60)
const cards = groupSeries(page.items).slice(0, 12)
return { library: lib, cards } as LibraryRow
} catch {
return { library: lib, cards: [] } as LibraryRow
}
}),
)
const perLib = await Promise.all(libs.map(async (lib) => {
try {
const page = await libraryAPI.listMedia(lib.id, 1, 60)
return { library: lib, cards: groupSeries(page.items).slice(0, 14) } as LibraryRow
} catch { return { library: lib, cards: [] } as LibraryRow }
}))
if (cancelled) return
setRows(perLib)
} finally {
if (!cancelled) setLoading(false)
}
} finally { if (!cancelled) setLoading(false) }
}
load()
return () => {
cancelled = true
}
return () => { cancelled = true }
}, [])
// 兜底:如果一个库都没有,但 /api/media 有内容(旧数据),回退展示一个总卡片墙。
const [fallback, setFallback] = useState<Media[]>([])
useEffect(() => {
if (loading) return
if (libraries.length > 0) return
if (loading || libraries.length > 0) return
mediaAPI.search('', 60).then((d) => setFallback(d.items)).catch(() => undefined)
}, [loading, libraries.length])
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
const empty =
!loading &&
history.length === 0 &&
rows.every((r) => r.cards.length === 0) &&
fallbackCards.length === 0
// Pick a featured movie/show for the premium editorial billboard
const featuredItem = useMemo(() => {
if (history.length > 0 && history[0].media) {
return history[0].media;
}
if (rows.length > 0 && rows[0].cards.length > 0) {
return rows[0].cards[0].rep;
}
if (fallback.length > 0) {
return fallback[0];
}
return null;
}, [history, rows, fallback]);
const empty = !loading && history.length === 0 && rows.every((r) => r.cards.length === 0) && fallbackCards.length === 0
if (loading) {
return (
<div className="flex items-center justify-center py-48">
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 1.5 }} className="flex flex-col items-center gap-4">
<div className="relative flex items-center justify-center">
<div className="h-10 w-10 rounded-full border-2 border-gray-100 border-t-gray-900 animate-spin" />
<Film className="absolute h-4 w-4 text-brand-500" />
</div>
<span className="text-sm font-semibold tracking-widest text-gray-400 uppercase">站点舱准备中…</span>
</motion.div>
</div>
)
}
if (empty) {
return (
<div className="flex flex-col items-center justify-center py-32 text-center max-w-md mx-auto">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-3xl bg-gray-100 border border-gray-200/50 shadow-sm">
<Film className="h-10 w-10 text-gray-400" />
</div>
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
立即前往管理后台配置您的第一个媒体库,添加影视目录并触发后台刮削扫描任务。
</p>
<Link to="/admin" className="mt-8 btn-primary">
前往管理后台
</Link>
</div>
)
}
return (
<div className="animate-fade-in space-y-12">
{/* ── Header ── */}
<header className="flex items-end justify-between">
<div>
<h1 className="font-display text-3xl font-bold tracking-tight text-cream-100">
媒体中心
</h1>
<p className="mt-1.5 text-sm text-cream-400">
从你上次离开的地方继续
</p>
</div>
{!loading && (
<Link
to="/discover"
className="flex items-center gap-1.5 text-sm text-cream-400 transition-colors hover:text-brand-400"
>
<Compass size={14} />
发现更多
<ArrowRight size={14} />
</Link>
)}
</header>
{/* Loading */}
{loading && (
<div className="flex items-center gap-3 py-16">
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-500/60" />
<p className="text-sm text-cream-500">加载媒体库…</p>
</div>
)}
{/* Empty */}
{empty && (
<div className="surface-card flex flex-col items-center gap-4 py-16 text-center">
<Film className="h-12 w-12 text-cream-900/40" />
<div>
<p className="font-medium text-cream-300">还没有任何媒体</p>
<p className="mt-1 text-sm text-cream-500">
前往{' '}
<Link to="/admin" className="text-brand-400 underline underline-offset-2">
管理后台
</Link>{' '}
创建媒体库,然后触发一次扫描
</p>
<div className="space-y-12">
{/* ─── Premium Swiss-Editorial Billboard Hero ─── */}
{featuredItem && (
<section className="relative overflow-hidden rounded-3xl 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)]">
{/* Background Poster Cover & Soft Faded Backdrop */}
<div className="absolute inset-0 z-0">
{featuredItem.poster_url ? (
<img
src={imageURL(featuredItem.poster_url)}
alt=""
className="w-full h-full object-cover object-top opacity-5 scale-105 blur-sm"
/>
) : (
<div className="w-full h-full bg-gray-50" />
)}
{/* Soft Bright Masking */}
<div className="absolute inset-0 bg-gradient-to-r from-white via-white/95 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
</div>
</div>
{/* Billboard Content */}
<div className="relative z-10 px-8 py-14 sm:px-12 md:py-16 lg:py-20 max-w-3xl space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-brand-50 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#c9954a] border border-brand-100/40">
<Sparkles size={12} fill="currentColor" />
<span>本周力荐 / Featured</span>
</div>
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-900">
{featuredItem.title}
</h1>
{featuredItem.overview ? (
<p className="text-gray-500 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
{featuredItem.overview}
</p>
) : (
<p className="text-gray-400 text-sm italic">
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
</p>
)}
{/* Metadata Badges */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
{featuredItem.year > 0 && (
<span className="bg-gray-100 px-2.5 py-1 rounded-md text-gray-900 border border-gray-200">{featuredItem.year} 年</span>
)}
{featuredItem.video_codec && (
<span className="rounded bg-brand-50 px-2 py-1 text-brand-700 border border-brand-100 uppercase font-bold text-[10px]">
{featuredItem.video_codec}
</span>
)}
{featuredItem.container && (
<span className="rounded bg-gray-100 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
{featuredItem.container}
</span>
)}
</div>
{/* Buttons */}
<div className="flex flex-wrap items-center gap-4 pt-4">
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-sm hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link to="/discover" className="btn-outline px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
<span>发现更多精彩</span>
<ArrowRight size={16} />
</Link>
</div>
</div>
</section>
)}
{/* Continue Watching */}
{/* ─── Continue Watching Section ─── */}
{history.length > 0 && (
<section>
<SectionHeading icon={<Play size={16} />} label="继续观看" />
<div className="flex gap-4 overflow-x-auto pb-2">
{history.slice(0, 6).map((h) => {
<section className="space-y-5">
<div className="flex items-center gap-2.5">
<span className="p-1.5 rounded-lg bg-gray-100 border border-gray-200/50 text-gray-900">
<Clock size={16} />
</span>
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">继续观看</h2>
<span className="text-xs font-bold text-gray-400 bg-gray-100 px-2.5 py-0.5 rounded-full border border-gray-200/40">{history.length} 个记录</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{history.slice(0, 8).map((h) => {
const m = h.media!
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return <WideContinueCard key={h.id} media={m} progress={progress} />
const pct = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return <ContinueCard key={h.id} media={m} progress={pct} />
})}
</div>
</section>
)}
{/* 每个媒体库一行 */}
{!loading &&
rows
.filter((r) => r.cards.length > 0)
.map((row) => (
<LibrarySection key={row.library.id} row={row} />
))}
{/* ─── Media Libraries Grid Sections ─── */}
<div className="space-y-12">
{rows.filter((r) => r.cards.length > 0).map((row) => (
<section key={row.library.id} className="space-y-5">
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
<div className="flex items-center gap-2.5">
<span className="p-1.5 rounded-lg bg-gray-100 text-gray-900 border border-gray-200/50">
{TYPE_ICONS[row.library.type] ?? <LibraryIcon size={18} />}
</span>
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">
{row.library.name}
</h2>
<span className="rounded-full bg-gray-100 border border-gray-200/80 px-2 py-0.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">
{TYPE_LABELS[row.library.type] ?? row.library.type}
</span>
</div>
<Link to={`/library/${row.library.id}`} className="group inline-flex items-center gap-1 text-xs font-bold text-gray-600 hover:text-brand-600 transition-colors">
<span>浏览全部</span>
<ChevronRight size={14} className="transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
{/* 旧数据兜底 */}
{!loading && libraries.length === 0 && fallbackCards.length > 0 && (
<section>
<SectionHeading icon={<Clock size={16} />} label="最近添加" />
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{fallbackCards.map((s) => (
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
))}
</div>
</section>
)}
{/* Quick Links */}
{!empty && (
<section>
<SectionHeading icon={<Sparkles size={16} />} label="快捷入口" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<QuickLink to="/discover" label="发现" desc="探索新内容" />
<QuickLink to="/favourites" label="收藏" desc="我的珍藏" />
<QuickLink to="/history" label="历史" desc="观看记录" />
<QuickLink to="/ai" label="AI" desc="智能推荐" />
</div>
</section>
)}
</div>
)
}
/* ─── Library 行 ─── */
function LibrarySection({ row }: { row: LibraryRow }) {
const lib = row.library
const typeIcon = TYPE_ICONS[lib.type] ?? <LibraryIcon size={16} />
const typeLabel = TYPE_LABELS[lib.type] ?? lib.type
return (
<section>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-cream-400">{typeIcon}</span>
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
{lib.name}
</h2>
<span className="text-xs text-cream-500">·</span>
<span className="text-xs text-cream-500">{typeLabel}</span>
</div>
<Link
to={`/library/${lib.id}`}
className="flex items-center gap-1.5 text-xs text-cream-400 transition-colors hover:text-brand-400"
>
查看全部
<ArrowRight size={12} />
</Link>
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{row.cards.map((s) => (
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
{row.cards.map((s) => (
<MediaCard
key={s.rep.id}
media={s.rep}
count={s.count}
linkTo={s.count > 1 ? `/library/${row.library.id}` : undefined}
/>
))}
</div>
</section>
))}
</div>
</section>
)
}
/* ─── Section Heading ─── */
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="mb-4 flex items-center gap-2">
<span className="text-cream-400">{icon}</span>
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
{label}
</h2>
{/* ─── Fallback Recents Section ─── */}
{libraries.length === 0 && fallbackCards.length > 0 && (
<section className="space-y-5">
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
<div className="flex items-center gap-2.5">
<span className="p-1.5 rounded-lg bg-gray-100 text-gray-900 border border-gray-200/50">
<Clock size={18} />
</span>
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近添加</h2>
</div>
</div>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
{fallbackCards.map((s) => (
<MediaCard key={s.rep.id} media={s.rep} count={s.count} />
))}
</div>
</section>
)}
</div>
</div>
)
}
/* ─── Wide Continue-Watching Card ─── */
function WideContinueCard({ media, progress }: { media: Media; progress: number }) {
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
return (
<Link
to={`/media/${media.id}`}
className="group flex shrink-0 w-72 items-center gap-4 rounded-lg border border-cream-900/15 bg-surface-400 p-3 transition-all hover:border-brand-500/30 hover:bg-surface-300"
>
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600">
<Link to={`/media/${media.id}`} className="group flex items-center gap-4 rounded-2xl bg-white p-3.5 border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01)] transition-all duration-300 hover:shadow-md hover:border-brand-500/30">
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl bg-gray-50 border border-gray-100">
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
alt={media.title}
loading="lazy"
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
<img
src={imageURL(media.poster_url)}
alt=""
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 items-center justify-center text-cream-900/30">
<Film size={20} />
<div className="flex h-full items-center justify-center text-gray-400 bg-gray-50">
<Film size={16} />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100">
<Play size={14} fill="white" className="text-white" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
<div className="min-w-0 flex-1 space-y-1.5">
<p className="truncate text-sm font-bold text-gray-900 group-hover:text-brand-500 transition-colors">
{media.title}
</p>
{media.year > 0 && (
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
)}
{progress > 0 && (
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20">
<div
className="h-full rounded-full bg-brand-500/70 transition-all group-hover:bg-brand-400"
style={{ width: `${Math.round(progress * 100)}%` }}
<div className="space-y-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${Math.round(progress * 100)}%` }}
transition={{ duration: 0.5, delay: 0.1 }}
className="h-full rounded-full bg-gradient-to-r from-brand-400 to-brand-500"
/>
</div>
)}
</div>
<Play
size={14}
className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400"
/>
</Link>
)
}
/* ─── Poster Card (grid item) ─── */
function PosterCard({ media, count = 1 }: { media: Media; count?: 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"
>
<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>
)}
{count > 1 && (
<span className="absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
<Layers size={10} />
{count}
</span>
)}
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2.5 opacity-0 transition-opacity group-hover:opacity-100">
<span className="flex items-center gap-1 text-[11px] text-white/90">
<Play size={11} />
播放
</span>
<p className="text-[10px] text-gray-400 font-bold tracking-wide uppercase">
已观看到 {Math.round(progress * 100)}%
</p>
</div>
</div>
<div className="px-2.5 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>
</Link>
)
}
/* ─── Quick Link Tile ─── */
function QuickLink({ to, label, desc }: { to: string; label: string; desc: string }) {
return (
<Link
to={to}
className="group flex items-center gap-3 rounded-lg border border-cream-900/15 bg-surface-400 px-4 py-3.5 transition-all hover:border-brand-500/20 hover:bg-surface-300"
>
<div className="flex-1">
<p className="text-sm font-medium text-cream-300 group-hover:text-cream-100">{label}</p>
<p className="mt-0.5 text-xs text-cream-500">{desc}</p>
</div>
<ArrowRight size={14} className="text-cream-600 transition-colors group-hover:text-brand-400" />
</Link>
)
}
}
+197 -79
View File
@@ -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]}`
}
+17 -17
View File
@@ -102,16 +102,16 @@ export function LicensePage() {
<KeySquare size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">许可证</h1>
<p className="text-sm text-slate-400">绑定授权密钥以解锁全部功能</p>
<h1 className="font-display text-3xl font-bold text-ink-600">许可证</h1>
<p className="text-sm text-ink-50">绑定授权密钥以解锁全部功能</p>
</div>
</div>
{/* ── Bind Form ── */}
<div className="glass-panel space-y-4">
<div className="flex items-center gap-2">
<Link2 size={18} className="text-primary-400" />
<h2 className="font-display text-lg font-semibold text-white">绑定许可证</h2>
<Link2 size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">绑定许可证</h2>
</div>
<form onSubmit={onBind} className="flex gap-3">
<input
@@ -126,14 +126,14 @@ export function LicensePage() {
绑定
</button>
</form>
<p className="text-xs text-slate-500">
<p className="text-xs text-sand-500">
输入从授权服务器获取的许可证密钥,激活后即可使用所有高级功能。
</p>
</div>
{/* ── License Status ── */}
{loadingStatus ? (
<div className="flex justify-center py-8 text-slate-400">
<div className="flex justify-center py-8 text-ink-50">
<Loader2 className="animate-spin" />
</div>
) : (
@@ -143,14 +143,14 @@ export function LicensePage() {
{active && !isExpired ? (
<CheckCircle2 size={18} className="text-emerald-400" />
) : (
<XCircle size={18} className="text-slate-500" />
<XCircle size={18} className="text-sand-500" />
)}
<h2 className="font-display text-lg font-semibold text-white">当前状态</h2>
<h2 className="font-display text-lg font-semibold text-ink-600">当前状态</h2>
</div>
<div className="flex gap-2">
<button
onClick={refreshStatus}
className="rounded border border-white/10 p-2 text-slate-400 hover:bg-white/5 hover:text-white"
className="rounded border border-white/10 p-2 text-ink-50 hover:bg-white/5 hover:text-white"
title="刷新状态"
>
<RefreshCw size={14} />
@@ -158,7 +158,7 @@ export function LicensePage() {
{active && (
<button
onClick={onHeartbeat}
className="rounded border border-primary-400/30 px-3 py-1.5 text-xs text-primary-400 hover:bg-primary-400/10"
className="rounded border border-primary-400/30 px-3 py-1.5 text-xs text-brand-500 hover:bg-primary-400/10"
>
心跳上报
</button>
@@ -168,7 +168,7 @@ export function LicensePage() {
{!active && (
<div className="space-y-3 rounded-lg border border-white/5 bg-white/[0.02] p-4 text-center">
<p className="text-slate-400">
<p className="text-ink-50">
尚未绑定许可证。请在上方输入密钥完成激活。
</p>
</div>
@@ -184,8 +184,8 @@ export function LicensePage() {
activation.plan === 'enterprise'
? 'text-amber-300'
: activation.plan === 'pro'
? 'text-primary-400'
: 'text-slate-300'
? 'text-brand-500'
: 'text-ink-100'
}
/>
<StatusBadge label="设备名称" value={activation.device_name || 'Web Client'} />
@@ -212,10 +212,10 @@ export function LicensePage() {
{/* ── Pro tip ── */}
{!active && !loadingStatus && (
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-5 text-center">
<p className="text-sm text-slate-500">
<p className="text-sm text-sand-500">
需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
</p>
<p className="mt-1 text-xs text-slate-600">
<p className="mt-1 text-xs text-sand-400">
授权服务器地址可在系统设置中配置
</p>
</div>
@@ -239,8 +239,8 @@ function StatusBadge({
}) {
return (
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3">
<p className="mb-0.5 text-xs text-slate-500">{label}</p>
<p className={`text-sm text-white ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
<p className="mb-0.5 text-xs text-sand-500">{label}</p>
<p className={`text-sm text-ink-600 ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
{value}
</p>
</div>
+168 -61
View File
@@ -1,19 +1,18 @@
import { FormEvent, useState } from 'react'
import { FormEvent, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import { Film } from 'lucide-react'
import { Eye, EyeOff, Film, ArrowRight, ShieldCheck, Lock, User } from 'lucide-react'
import { AppFooter } from '../components/AppFooter'
import { authAPI } from '../api/auth'
import { useAuthStore } from '../stores/auth'
// Single-form login screen. The first install seeds admin/admin123 — we
// surface that hint when no JWT exists yet.
export function LoginPage() {
const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPw, setShowPw] = useState(false)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => {
@@ -25,67 +24,175 @@ export function LoginPage() {
toast.success(`欢迎回来, ${data.user.username}`)
navigate('/')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'登录失败,请检查用户名与密码'
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '登录失败'
toast.error(msg)
} finally {
setLoading(false)
}
} finally { setLoading(false) }
}
return (
<div className="flex min-h-screen flex-col items-center px-4">
{/* 表单居中区域:flex-grow + flex center 使表单在剩余空间内垂直居中 */}
<div className="flex flex-1 flex-col items-center justify-center">
<form onSubmit={handleSubmit} className="glass-panel w-full max-w-md">
<div className="mb-8 flex flex-col items-center gap-2">
<Film className="h-10 w-10 text-primary-400" />
<h1 className="font-display text-2xl font-bold tracking-wide text-white">
MediaStationGo
</h1>
<p className="text-sm text-slate-400">登录到你的家庭媒体中心</p>
</div>
<label className="mb-4 block">
<span className="mb-1 block text-sm text-slate-300">用户名</span>
<input
className="input-base"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="mb-6 block">
<span className="mb-1 block text-sm text-slate-300">密码</span>
<input
type="password"
className="input-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button
type="submit"
disabled={loading}
className="neon-button w-full justify-center"
>
{loading ? '登录中…' : '登录'}
</button>
<p className="mt-6 text-center text-xs text-slate-500">
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
</p>
</form>
<div className="relative flex min-h-screen flex-col items-center justify-center bg-gray-50/50 overflow-hidden px-4">
{/* ── Editorial Soft Geometric Background Decor ── */}
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute -right-20 -top-20 h-[600px] w-[600px] rounded-full bg-brand-100/30 blur-[130px]" />
<div className="absolute -bottom-40 -left-20 h-[500px] w-[500px] rounded-full bg-sage-100/30 blur-[110px]" />
<div className="absolute inset-0 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:20px_20px] opacity-40" />
</div>
{/* 页脚始终在底部 */}
<AppFooter className="mb-4 mt-auto" />
{/* ── Main Container ── */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
className="relative z-10 w-full max-w-[420px] px-2"
>
{/* High-Contrast Swiss White Panel */}
<div className="rounded-3xl border border-gray-200/90 bg-white p-8 sm:p-10 shadow-[0_25px_60px_rgba(17,24,39,0.04),0_1px_3px_rgba(17,24,39,0.01)]">
{/* Logo & Headline */}
<div className="flex flex-col items-center text-center pb-8">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#111827] shadow-sm"
>
<Film className="h-7 w-7 text-[#c9954a]" />
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
className="font-display text-2xl font-extrabold tracking-tight text-gray-900"
>
MediaStation
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.35 }}
className="mt-2 text-xs font-bold uppercase tracking-widest text-[#c9954a]"
>
家庭媒体私有站舱
</motion.p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Username Input with Integrated Icon */}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
>
<label className="block mb-2 text-xs font-bold uppercase tracking-widest text-gray-500">
用户名
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
<User size={16} />
</span>
<input
type="text"
className="w-full rounded-xl border border-gray-200 bg-gray-50/50 px-11 py-3.5 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/50"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
placeholder="请输入您的账号"
/>
</div>
</motion.div>
{/* Password Input with Integrated Icon */}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.45 }}
>
<label className="block mb-2 text-xs font-bold uppercase tracking-widest text-gray-500">
安全密码
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
<Lock size={16} />
</span>
<input
type={showPw ? 'text' : 'password'}
className="w-full rounded-xl border border-gray-200 bg-gray-50/50 px-11 py-3.5 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/50 pr-12"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPw(!showPw)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-900"
tabIndex={-1}
>
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</motion.div>
{/* Submit Button */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="pt-2"
>
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-[#111827] py-3.5 text-sm font-bold text-white transition-all duration-300 hover:bg-[#1f2937] hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] disabled:opacity-50"
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin text-white" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
正在进入系统舱...
</span>
) : (
<span className="flex items-center gap-2">
立即开启观影之旅 <ArrowRight size={16} />
</span>
)}
</button>
</motion.div>
{/* Quick Helper Credentials */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.55 }}
className="flex items-center justify-center gap-1.5 rounded-xl bg-gray-50 border border-gray-100 px-4 py-2.5 text-xs text-gray-500"
>
<ShieldCheck size={14} className="text-brand-500" />
<span>默认访问凭证: </span>
<code className="rounded bg-gray-200/50 px-1.5 py-0.5 font-mono text-[#111827] font-semibold">
admin / admin123
</code>
</motion.div>
</form>
</div>
</motion.div>
{/* Footer */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="relative z-10 mt-10 text-gray-400 hover:text-gray-600 transition-colors"
>
<AppFooter />
</motion.div>
</div>
)
}
}
+215 -103
View File
@@ -1,6 +1,7 @@
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2 } from 'lucide-react'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database } from 'lucide-react'
import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
@@ -35,14 +36,6 @@ function parseCSV(s?: string): string[] {
return s.split(',').map(x => x.trim()).filter(Boolean)
}
// Detail screen for a single media item.
//
// Buttons:
// - Play (direct play)
// - HLS (force transcoded play)
// - Favourite (toggle, all users)
// - Re-scrape (admin only, triggers TMDb lookup)
// - Re-probe (admin only, refreshes ffprobe metadata)
export function MediaDetailPage() {
const { id = '' } = useParams()
const navigate = useNavigate()
@@ -71,13 +64,13 @@ export function MediaDetailPage() {
if (!media) return
const state = await playbackAPI.toggleFavourite(media.id)
setFavourite(state)
toast.success(state ? '已加入收藏' : '已取消收藏')
toast.success(state ? '已加入我的收藏' : '已取消收藏')
}
const rescrape = async () => {
if (!media) return
await api.post(`/media/${media.id}/scrape`)
toast.success('已刮削')
toast.success('已触发重新刮削')
await refresh()
}
@@ -86,7 +79,7 @@ export function MediaDetailPage() {
try {
const r = await api.post(`/media/${media.id}/probe`)
if (r.data?.code === 0) {
toast.success('已重新探测')
toast.success('重新探测成功')
} else {
toast.error(r.data?.error || '探测失败')
}
@@ -103,7 +96,7 @@ export function MediaDetailPage() {
if (!media) return
try {
const r = await recycleAPI.exportNFO(media.id)
toast.success(`NFO 已写入 ${r.path}`)
toast.success(`NFO 已成功写入 ${r.path}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
@@ -120,106 +113,225 @@ export function MediaDetailPage() {
navigate(-1)
}
if (loading) return <p className="text-slate-500">加载中…</p>
if (!media) return <p className="text-slate-500">媒体不存在</p>
if (loading) {
return (
<div className="flex items-center justify-center py-48">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-100 border-t-gray-900" />
</div>
)
}
if (!media) {
return (
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
<p className="text-gray-500">媒体资源已被移除或不存在</p>
</div>
)
}
return (
<div className="space-y-8">
<div className="flex flex-col gap-6 md:flex-row">
<div className="aspect-[2/3] w-48 shrink-0 overflow-hidden rounded-xl bg-surface-800">
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
alt={media.title}
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600">
无海报
</div>
)}
</div>
<div className="flex-1 space-y-4">
<h1 className="font-display text-3xl font-bold text-white">{media.title}</h1>
{media.year > 0 && <p className="text-slate-400">{media.year}</p>}
{media.overview && <p className="text-slate-300">{media.overview}</p>}
<div className="relative overflow-hidden rounded-3xl 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)]">
{/* ── Cinematic Blurred Backdrop Glow ── */}
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
alt=""
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-transparent" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/95 to-transparent" />
</div>
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
{media.video_codec && <Badge>{media.video_codec.toUpperCase()}</Badge>}
{media.width > 0 && (
<Badge>
{media.width}×{media.height}
</Badge>
{/* ── Main Details Layout Container ── */}
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
{/* Poster Card */}
<div className="w-56 shrink-0 mx-auto md:mx-0">
<motion.div
whileHover={{ scale: 1.02 }}
className="aspect-[2/3] w-full rounded-2xl overflow-hidden bg-gray-50 border border-gray-200 shadow-md relative group"
>
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
alt={media.title}
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 flex-col items-center justify-center gap-2 text-gray-400 bg-gray-50">
<FileText size={40} className="stroke-[1]" />
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
</div>
)}
<Badge>{fmtDuration(media.duration_sec)}</Badge>
<Badge>{fmtSize(media.size_bytes)}</Badge>
{media.container && <Badge>{media.container.toUpperCase()}</Badge>}
<Badge>{media.scrape_status}</Badge>
{parseCSV(media.languages).map(l => (
<Badge key={l} className="border-blue-400/30 text-blue-400 bg-blue-400/10">{l}</Badge>
))}
{parseCSV(media.countries).map(c => (
<Badge key={c} className="border-green-400/30 text-green-400 bg-green-400/10">{c}</Badge>
))}
{parseCSV(media.genres).map(g => (
<Badge key={g} className="border-purple-400/30 text-purple-400 bg-purple-400/10">{g}</Badge>
))}
{/* Quick Play overlay button */}
<Link
to={`/play/${media.id}`}
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-brand-500 text-white shadow-xl transform scale-90 group-hover:scale-100 transition-transform">
<Play size={24} fill="currentColor" />
</div>
</Link>
</motion.div>
</div>
{/* Detailed Metadata Body */}
<div className="flex-1 space-y-6">
{/* Title and Year Header */}
<div className="space-y-3">
<h1 className="font-display text-3xl sm:text-4xl font-extrabold tracking-tight text-gray-900 leading-tight">
{media.title}
</h1>
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
{media.year > 0 && (
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
<Calendar size={13} className="text-brand-500" />
<span>{media.year} 年</span>
</span>
)}
{media.width > 0 && (
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-lg">
<span>{media.width} × {media.height}</span>
</span>
)}
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
{fmtSize(media.size_bytes)}
</span>
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
{fmtDuration(media.duration_sec)}
</span>
{media.container && (
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700 font-mono">
{media.container}
</span>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link to={`/play/${media.id}`} className="neon-button">
<Play size={18} /> 播放
</Link>
<Link
to={`/play/${media.id}?mode=hls`}
className="neon-button !border-accent-400/40 !bg-accent-400/10 !text-accent-400 hover:!border-accent-400"
>
<RefreshCw size={16} /> HLS 转码播放
</Link>
<button
onClick={toggleFav}
className={
'neon-button ' +
(favourite
? '!border-pink-400/60 !bg-pink-400/10 !text-pink-400'
: '')
}
>
<Heart size={16} fill={favourite ? 'currentColor' : 'none'} />
{favourite ? '已收藏' : '收藏'}
</button>
{/* Description Card */}
{media.overview && (
<div className="rounded-2xl bg-gray-50/50 border border-gray-100 p-5 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-500">剧情简介</h3>
<p className="text-sm text-gray-600 leading-relaxed font-semibold">
{media.overview}
</p>
</div>
)}
{/* Tag Rows (Genres, Languages, Countries) */}
<div className="space-y-4">
{/* Genres */}
{parseCSV(media.genres).length > 0 && (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">类型流派</span>
<div className="flex flex-wrap gap-2">
{parseCSV(media.genres).map((g) => (
<span key={g} className="rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider">
{g}
</span>
))}
</div>
</div>
)}
{/* Languages */}
{parseCSV(media.languages).length > 0 && (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">语言</span>
<div className="flex flex-wrap gap-2">
{parseCSV(media.languages).map((l) => (
<span key={l} className="rounded-lg bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
{l}
</span>
))}
</div>
</div>
)}
{/* Countries */}
{parseCSV(media.countries).length > 0 && (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">国家/地区</span>
<div className="flex flex-wrap gap-2">
{parseCSV(media.countries).map((c) => (
<span key={c} className="rounded-lg bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
{c}
</span>
))}
</div>
</div>
)}
</div>
<div className="divider border-gray-200/60" />
{/* Action Buttons Panel */}
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
{/* Primary Direct Play */}
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
{/* Transcode Playback */}
<Link
to={`/play/${media.id}?mode=hls`}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
>
<RefreshCw size={14} className="animate-spin-slow" />
<span>HLS 兼容转码播放</span>
</Link>
{/* Toggle Favourites */}
<button
onClick={toggleFav}
className={
'btn-outline gap-2 ' +
(favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
}
>
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
</button>
</div>
{/* Admin Management Toolbar */}
{role === 'admin' && (
<>
<button onClick={rescrape} className="neon-button">
<Sparkles size={16} /> 重新刮削
</button>
<button onClick={reprobe} className="neon-button">
<RefreshCw size={16} /> 重新探测
</button>
<button onClick={exportNFO} className="neon-button">
<FileText size={16} /> 导出 NFO
</button>
<button
onClick={softDelete}
className="neon-button !border-red-400/40 !bg-red-400/10 !text-red-400"
>
<Trash2 size={16} /> 移至回收站
</button>
</>
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
<div className="flex flex-wrap gap-2">
<button onClick={rescrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Sparkles size={13} className="text-[#c9954a]" />
<span>智能刮削 (TMDB)</span>
</button>
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-gray-600" />
<span>探测媒体轨 (ffprobe)</span>
</button>
<button onClick={exportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FileText size={13} />
<span>写出本地 NFO 属性</span>
</button>
<button
onClick={softDelete}
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
>
<Trash2 size={13} />
<span>移入回收站</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function Badge({ children, className = '' }: { children: React.ReactNode; className?: string }) {
const base = "rounded border border-white/10 bg-white/5 px-2 py-0.5"
return (
<span className={`${base} ${className}`}>
{children}
</span>
)
}
}
+14 -14
View File
@@ -62,8 +62,8 @@ export function NotifyChannelsPage() {
<Bell size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">通知渠道</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">通知渠道</h1>
<p className="text-sm text-ink-50">
配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
</p>
</div>
@@ -80,13 +80,13 @@ export function NotifyChannelsPage() {
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)}
{!loading && channels.length === 0 && (
<div className="glass-panel py-12 text-center text-slate-400">暂无通知渠道</div>
<div className="glass-panel py-12 text-center text-ink-50">暂无通知渠道</div>
)}
{!loading && channels.length > 0 && (
@@ -144,26 +144,26 @@ function ChannelCard({
<div className="glass-panel flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{channel.name}</span>
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
<span className="font-medium text-ink-600">{channel.name}</span>
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-ink-50">
{TYPE_LABELS[channel.channel_type] ?? channel.channel_type}
</span>
{!channel.enabled && (
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">已禁用</span>
<span className="rounded bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
)}
</div>
<div className="mt-1 truncate text-xs text-slate-400">{summary}</div>
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={onTest}
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Send size={12} className="inline" /> 测试
</button>
<button
onClick={onEdit}
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
@@ -262,7 +262,7 @@ function ChannelFormModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-white">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑通知渠道' : '添加通知渠道'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
@@ -460,7 +460,7 @@ function ChannelFormModal({
</>
)}
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-300">
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
@@ -474,7 +474,7 @@ function ChannelFormModal({
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
>
取消
</button>
@@ -491,7 +491,7 @@ function ChannelFormModal({
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
+3 -3
View File
@@ -130,7 +130,7 @@ export function PlayerPage() {
<div className="-m-6 flex min-h-screen flex-col bg-black md:-m-8">
<button
onClick={() => navigate(-1)}
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur transition hover:bg-black/80"
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
>
<ArrowLeft size={16} /> 返回
</button>
@@ -142,7 +142,7 @@ export function PlayerPage() {
params.set('mode', next)
setParams(params, { replace: true })
}}
className="absolute right-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur transition hover:bg-black/80"
className="absolute right-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
title="切换播放模式"
>
{mode === 'hls' ? (
@@ -189,7 +189,7 @@ export function PlayerPage() {
))}
</video>
) : (
<p className="text-slate-500">加载中…</p>
<p className="text-sand-500">加载中…</p>
)}
</div>
</div>
+5 -5
View File
@@ -18,18 +18,18 @@ export function PlaylistDetailPage() {
}, [id])
if (!detail) {
return <p className="text-slate-500">加载中…</p>
return <p className="text-sand-500">加载中…</p>
}
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold text-white">{detail.playlist.name}</h1>
<p className="text-sm text-slate-400">{detail.items.length} 部影片</p>
<h1 className="font-display text-3xl font-bold text-ink-600">{detail.playlist.name}</h1>
<p className="text-sm text-ink-50">{detail.items.length} 部影片</p>
</header>
{detail.items.length === 0 && (
<p className="text-slate-400">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
<p className="text-ink-50">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
@@ -37,7 +37,7 @@ export function PlaylistDetailPage() {
<div key={m.id} className="relative">
<MediaCard media={m} />
<button
className="absolute right-2 top-2 rounded-full bg-black/70 p-1 text-white transition hover:bg-red-600"
className="absolute right-2 top-2 rounded-full bg-black/70 p-1 text-ink-600 transition hover:bg-red-600"
onClick={async () => {
await playbackAPI.removeFromPlaylist(detail.playlist.id, m.id)
toast.success('已移除')
+5 -5
View File
@@ -37,7 +37,7 @@ export function PlaylistsPage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">播放列表</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">播放列表</h1>
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_auto]">
<input
@@ -52,8 +52,8 @@ export function PlaylistsPage() {
</button>
</form>
{loading && <p className="text-slate-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-slate-400">还没有任何播放列表。</p>}
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-ink-50">还没有任何播放列表。</p>}
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((p) => (
@@ -63,11 +63,11 @@ export function PlaylistsPage() {
>
<Link
to={`/playlist/${p.id}`}
className="font-medium text-white transition hover:text-primary-400"
className="font-medium text-ink-600 transition hover:text-brand-500"
>
{p.name}
{p.is_public && (
<span className="ml-2 rounded border border-primary-400/40 px-1.5 py-0.5 text-xs text-primary-400">
<span className="ml-2 rounded border border-primary-400/40 px-1.5 py-0.5 text-xs text-brand-500">
public
</span>
)}
+6 -6
View File
@@ -26,25 +26,25 @@ export function PosterWallPage() {
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<GalleryHorizontalEnd className="h-6 w-6 text-primary-400" />
<GalleryHorizontalEnd className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-white">海报墙</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">海报墙</h1>
<p className="text-sm text-ink-50">
按剧集聚合 · 共 {cards.length} 个条目
</p>
</div>
</header>
{loading && <p className="text-slate-500">加载中…</p>}
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && cards.length === 0 && (
<p className="text-slate-400">暂无媒体。请先添加媒体库并扫描。</p>
<p className="text-ink-50">暂无媒体。请先添加媒体库并扫描。</p>
)}
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10">
{cards.map((s) => (
<div key={s.rep.id} className="relative">
<MediaCard media={s.rep} />
{s.count > 1 && (
<span className="pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
<span className="pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-ink-600 backdrop-blur-sm">
<Layers size={10} />
{s.count}
</span>
+19 -19
View File
@@ -72,8 +72,8 @@ export function ProfileManagementPage() {
<UserCog size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">观影 Profile</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">观影 Profile</h1>
<p className="text-sm text-ink-50">
为不同场景(儿童 / 影院 / 成人)定义独立的内容分级和媒体库访问规则
</p>
</div>
@@ -84,7 +84,7 @@ export function ProfileManagementPage() {
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)}
@@ -92,8 +92,8 @@ export function ProfileManagementPage() {
{!loading && profiles.length === 0 && (
<div className="glass-panel py-12 text-center">
<div className="mb-2 text-4xl">👤</div>
<p className="font-medium text-white">暂无 Profile</p>
<p className="text-sm text-slate-400">点击右上角"创建 Profile"开始</p>
<p className="font-medium text-ink-600">暂无 Profile</p>
<p className="text-sm text-ink-50">点击右上角"创建 Profile"开始</p>
</div>
)}
@@ -149,7 +149,7 @@ function ProfileCard({
<div className="glass-panel flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-white"
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-ink-600"
style={{
background: `hsl(${(profile.name.charCodeAt(0) * 47) % 360}, 60%, 35%)`,
}}
@@ -158,9 +158,9 @@ function ProfileCard({
</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-white">{profile.name}</span>
<span className="font-semibold text-ink-600">{profile.name}</span>
{profile.is_default && (
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
默认
</span>
)}
@@ -175,12 +175,12 @@ function ProfileCard({
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-50">
{profile.content_rating_limit && <span>分级: {profile.content_rating_limit}</span>}
<span>媒体库: {libNames}</span>
<span>用户: {profile.user_id.slice(0, 8)}…</span>
</div>
<div className="text-xs text-slate-500">
<div className="text-xs text-sand-500">
观看时长 {Math.round(profile.total_watch_time / 3600)} 小时
{profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
</div>
@@ -189,7 +189,7 @@ function ProfileCard({
<div className="flex shrink-0 gap-2">
<button
onClick={onEdit}
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
@@ -267,7 +267,7 @@ function ProfileFormModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-white">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑 Profile' : '创建 Profile'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
@@ -388,15 +388,15 @@ function ProfileFormModal({
className={
'rounded-full border px-3 py-1 text-xs ' +
(form.allowed_library_ids.includes(l.id)
? 'border-primary-400/60 bg-primary-400/10 text-primary-400'
: 'border-white/10 text-slate-400 hover:text-white')
? 'border-primary-400/60 bg-primary-400/10 text-brand-500'
: 'border-white/10 text-ink-50 hover:text-white')
}
>
{l.name}
</button>
))}
{libraries.length === 0 && (
<span className="text-xs text-slate-500">暂无媒体库</span>
<span className="text-xs text-sand-500">暂无媒体库</span>
)}
</div>
</Field>
@@ -405,7 +405,7 @@ function ProfileFormModal({
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
>
取消
</button>
@@ -423,7 +423,7 @@ function ProfileFormModal({
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
@@ -443,8 +443,8 @@ function Toggle({
return (
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
<div>
<div className="text-sm text-white">{label}</div>
{hint && <div className="text-xs text-slate-400">{hint}</div>}
<div className="text-sm text-ink-600">{label}</div>
{hint && <div className="text-xs text-ink-50">{hint}</div>}
</div>
<input
type="checkbox"
+4 -4
View File
@@ -43,10 +43,10 @@ export function ProfilePage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">个人资料</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">个人资料</h1>
<form onSubmit={onProfile} className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-white">基本信息</h2>
<h2 className="font-display text-lg font-semibold text-ink-600">基本信息</h2>
<Field label="用户名">
<input className="input-base" value={user?.username ?? ''} disabled />
</Field>
@@ -74,7 +74,7 @@ export function ProfilePage() {
</form>
<form onSubmit={onPwd} className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-white">修改密码</h2>
<h2 className="font-display text-lg font-semibold text-ink-600">修改密码</h2>
<Field label="当前密码">
<input
required
@@ -107,7 +107,7 @@ export function ProfilePage() {
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
+8 -8
View File
@@ -21,18 +21,18 @@ export function RecycleBinPage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">回收站</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
<p className="text-sm text-ink-50">
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
</p>
{loading && <p className="text-slate-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-slate-400">回收站为空。</p>}
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-ink-50">回收站为空。</p>}
{items.length > 0 && (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">标题</th>
<th>路径</th>
@@ -42,13 +42,13 @@ export function RecycleBinPage() {
<tbody>
{items.map((m) => (
<tr key={m.id} className="border-t border-white/5">
<td className="py-2 text-white">{m.title}</td>
<td className="max-w-md truncate text-slate-400" title={m.path}>
<td className="py-2 text-ink-600">{m.title}</td>
<td className="max-w-md truncate text-ink-50" title={m.path}>
{m.path}
</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
await recycleAPI.restore(m.id)
toast.success('已恢复')
+8 -8
View File
@@ -34,10 +34,10 @@ export function SchedulerPage() {
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Clock className="h-6 w-6 text-primary-400" />
<Clock className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-white">定时任务</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">定时任务</h1>
<p className="text-sm text-ink-50">
后端周期性任务(媒体库扫描、转码缓存清理、回收站自动清理),每 5 秒刷新状态。
</p>
</div>
@@ -45,7 +45,7 @@ export function SchedulerPage() {
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">任务</th>
<th>间隔</th>
@@ -57,9 +57,9 @@ export function SchedulerPage() {
<tbody>
{jobs.map((j) => (
<tr key={j.name} className="border-t border-white/5">
<td className="py-2 font-mono text-white">{j.name}</td>
<td className="text-slate-300">{j.interval}</td>
<td className="text-slate-400">
<td className="py-2 font-mono text-ink-600">{j.name}</td>
<td className="text-ink-100">{j.interval}</td>
<td className="text-ink-50">
{j.last_run && new Date(j.last_run).getFullYear() > 2000
? new Date(j.last_run).toLocaleString()
: '尚未运行'}
@@ -69,7 +69,7 @@ export function SchedulerPage() {
<button
onClick={() => runNow(j.name)}
disabled={running === j.name}
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
>
<Play size={12} className="inline" /> 立即运行
</button>
+8 -8
View File
@@ -84,7 +84,7 @@ export function SearchPage() {
return (
<div className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-white">搜索</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">搜索</h1>
<button
disabled={!aiAvailable}
className={
@@ -122,14 +122,14 @@ export function SearchPage() {
)}
{intent && (
<div className="glass-panel !p-3 text-xs text-slate-300">
<div className="glass-panel !p-3 text-xs text-ink-100">
AI 解析:
<span className="ml-2 font-mono text-primary-400">{JSON.stringify(intent)}</span>
<span className="ml-2 font-mono text-brand-500">{JSON.stringify(intent)}</span>
</div>
)}
{loading && (
<div className="flex items-center gap-2 py-8 text-slate-400">
<div className="flex items-center gap-2 py-8 text-ink-50">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
搜索中…
</div>
@@ -141,8 +141,8 @@ export function SearchPage() {
{showIdle && (
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
<p className="text-lg text-slate-300">输入关键词开始搜索</p>
<p className="text-sm text-slate-500">
<p className="text-lg text-ink-100">输入关键词开始搜索</p>
<p className="text-sm text-sand-500">
支持电影、电视剧、动漫等媒体内容的快速搜索
</p>
</div>
@@ -150,8 +150,8 @@ export function SearchPage() {
{showEmpty && (
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
<p className="text-lg text-slate-300">未找到匹配的媒体</p>
<p className="text-sm text-slate-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
<p className="text-lg text-ink-100">未找到匹配的媒体</p>
<p className="text-sm text-sand-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
</div>
)}
+19 -19
View File
@@ -239,12 +239,12 @@ export function SettingsPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-400/10 text-slate-300">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-sand-300/40 text-ink-100">
<SettingsIcon size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">系统设置</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">系统设置</h1>
<p className="text-sm text-ink-50">
按分组编辑转码 / 整理 / 刮削 / 下载器等关键配置
</p>
</div>
@@ -258,8 +258,8 @@ export function SettingsPage() {
className={
'border-b-2 px-4 py-2 text-sm whitespace-nowrap transition ' +
(activeGroup === g.key
? 'border-primary-400 text-primary-400'
: 'border-transparent text-slate-400 hover:text-white')
? 'border-primary-400 text-brand-500'
: 'border-transparent text-ink-50 hover:text-white')
}
>
{g.label}
@@ -268,14 +268,14 @@ export function SettingsPage() {
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)}
{!loading && (
<form onSubmit={onSave} className="glass-panel space-y-4">
{group.description && <p className="text-xs text-slate-500">{group.description}</p>}
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
{group.items.map((it) => (
<SettingRow
key={it.key}
@@ -285,7 +285,7 @@ export function SettingsPage() {
/>
))}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-slate-500">
<span className="text-xs text-sand-500">
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
</span>
<button
@@ -303,12 +303,12 @@ export function SettingsPage() {
{/* 整理 tab 时显示各媒体库默认路径 */}
{!loading && activeGroup === 'organize' && libraries.length > 0 && (
<div className="glass-panel">
<div className="mb-3 flex items-center gap-2 text-sm text-slate-300">
<FolderOpen size={16} className="text-primary-400" />
<div className="mb-3 flex items-center gap-2 text-sm text-ink-100">
<FolderOpen size={16} className="text-brand-500" />
<span>默认整理路径参考(未设目标目录时按媒体库归类)</span>
</div>
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">媒体库</th>
<th>类型</th>
@@ -319,12 +319,12 @@ export function SettingsPage() {
<tbody>
{libraries.map((lib) => (
<tr key={lib.id} className="border-t border-white/5">
<td className="py-2 font-medium text-white">{lib.name}</td>
<td className="text-slate-400">
<td className="py-2 font-medium text-ink-600">{lib.name}</td>
<td className="text-ink-50">
{lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'}
</td>
<td className="font-mono text-xs text-slate-400">{lib.path}</td>
<td className="font-mono text-[11px] text-slate-500">
<td className="font-mono text-xs text-ink-50">{lib.path}</td>
<td className="font-mono text-[11px] text-sand-500">
{lib.type === 'movie'
? `${lib.path}/片名 (2024)/片名 (2024).mkv`
: lib.type === 'tv' || lib.type === 'anime'
@@ -352,10 +352,10 @@ function SettingRow({
}) {
return (
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
<label className="text-sm text-slate-300">
<label className="text-sm text-ink-100">
<div className="font-medium">{def.label}</div>
{def.hint && <div className="mt-0.5 text-xs text-slate-500">{def.hint}</div>}
<div className="mt-0.5 font-mono text-[10px] text-slate-600">{def.key}</div>
{def.hint && <div className="mt-0.5 text-xs text-sand-500">{def.hint}</div>}
<div className="mt-0.5 font-mono text-[10px] text-sand-400">{def.key}</div>
</label>
<div>
{def.type === 'text' && (
@@ -402,7 +402,7 @@ function SettingRow({
checked={value === 'true' || value === '1' || value === 'on'}
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
/>
<span className="text-sm text-slate-300">{value === 'true' ? '已启用' : '已关闭'}</span>
<span className="text-sm text-ink-100">{value === 'true' ? '已启用' : '已关闭'}</span>
</label>
)}
</div>
+10 -10
View File
@@ -66,10 +66,10 @@ export function SiteSearchPage() {
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Search className="h-6 w-6 text-primary-400" />
<Search className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-white">站点搜索</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">站点搜索</h1>
<p className="text-sm text-ink-50">
跨站搜索所有已启用的 PT/BT 站点,结果按做种数排序。
</p>
</div>
@@ -88,11 +88,11 @@ export function SiteSearchPage() {
</button>
</form>
{loading && <p className="text-slate-500">正在查询所有站点…</p>}
{loading && <p className="text-sand-500">正在查询所有站点…</p>}
{!loading && searched && results.length === 0 && (
<div className="glass-panel">
<p className="text-slate-300">
<p className="text-ink-100">
未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 Cookie / API Key 有效。
</p>
</div>
@@ -101,7 +101,7 @@ export function SiteSearchPage() {
{results.length > 0 && (
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">站点</th>
<th>标题</th>
@@ -115,13 +115,13 @@ export function SiteSearchPage() {
<tbody>
{results.map((item, idx) => (
<tr key={idx} className="border-t border-white/5 align-top">
<td className="py-2 text-primary-400">{item.site_name}</td>
<td className="py-2 text-brand-500">{item.site_name}</td>
<td className="max-w-md py-2">
<a
href={item.torrent_url}
target="_blank"
rel="noopener noreferrer"
className="text-white transition hover:text-primary-400"
className="text-white transition hover:text-brand-500"
title={item.title}
>
{item.title.length > 80
@@ -129,7 +129,7 @@ export function SiteSearchPage() {
: item.title}
</a>
</td>
<td className="whitespace-nowrap text-slate-300">
<td className="whitespace-nowrap text-ink-100">
{fmtBytes(item.size)}
</td>
<td className="text-emerald-400">{item.seeders || '—'}</td>
@@ -144,7 +144,7 @@ export function SiteSearchPage() {
<td className="py-2 text-right">
<button
onClick={() => downloadTorrent(item)}
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
title="加入下载"
>
<Download size={12} className="inline" />
+50 -50
View File
@@ -30,7 +30,7 @@ const SITE_TYPE_COLORS: Record<string, string> = {
unit3d: 'bg-orange-500/15 text-orange-400',
mteam: 'bg-green-500/15 text-green-400',
discuz: 'bg-yellow-500/15 text-yellow-400',
custom_rss: 'bg-slate-500/15 text-slate-400',
custom_rss: 'bg-sand-500/15 text-ink-50',
}
const AUTH_TYPE_LABELS: Record<string, string> = {
@@ -237,7 +237,7 @@ export function SitesPage() {
<div className="space-y-6">
{/* 页头 */}
<div className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-white">站点管理</h1>
<h1 className="font-display text-3xl font-bold text-ink-600">站点管理</h1>
<button onClick={openCreate} className="neon-button flex items-center gap-2">
<Plus size={16} />
添加站点
@@ -251,12 +251,12 @@ export function SitesPage() {
{/* 头部 */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || 'bg-slate-500/15 text-slate-400'}`}>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || 'bg-sand-500/15 text-ink-50'}`}>
{SITE_TYPE_ABBR[site.type] || '?'}
</div>
<div className="min-w-0">
<div className="font-medium text-white truncate">{site.name}</div>
<div className="text-xs text-slate-400 truncate max-w-[160px]">{site.url}</div>
<div className="font-medium text-ink-600 truncate">{site.name}</div>
<div className="text-xs text-ink-50 truncate max-w-[160px]">{site.url}</div>
</div>
</div>
{/* 状态指示 */}
@@ -268,22 +268,22 @@ export function SitesPage() {
<CheckCircle size={14} className="text-green-400" />
)
) : (
<HelpCircle size={14} className="text-slate-500" />
<HelpCircle size={14} className="text-sand-500" />
)}
{!site.enabled && <span className="text-xs text-slate-500 ml-1">已停用</span>}
{!site.enabled && <span className="text-xs text-sand-500 ml-1">已停用</span>}
</div>
</div>
{/* 标签 */}
<div className="flex flex-wrap gap-1.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-ink-50">
{SITE_TYPE_LABELS[site.type] || site.type}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-ink-50">
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
</span>
{site.is_default && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-400/15 text-primary-400">默认</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-400/15 text-brand-500">默认</span>
)}
{site.use_proxy && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">代理</span>
@@ -297,11 +297,11 @@ export function SitesPage() {
</div>
{/* 状态与统计(只读) */}
<div className="text-xs text-slate-500 space-y-0.5">
<div className="text-xs text-sand-500 space-y-0.5">
<div>状态:
<span className={
site.login_status === 'ok' ? 'text-green-400' :
site.login_status === 'failed' ? 'text-red-400' : 'text-slate-400'
site.login_status === 'failed' ? 'text-red-400' : 'text-ink-50'
}>
{site.login_status || 'unknown'}
</span>
@@ -319,7 +319,7 @@ export function SitesPage() {
<button
onClick={() => handleTest(site.id)}
disabled={testingId === site.id}
className="flex-1 rounded border border-white/10 px-2 py-1.5 text-xs text-slate-300 hover:bg-white/5 disabled:opacity-50 flex items-center justify-center gap-1 transition"
className="flex-1 rounded border border-white/10 px-2 py-1.5 text-xs text-ink-100 hover:bg-white/5 disabled:opacity-50 flex items-center justify-center gap-1 transition"
>
{testingId === site.id ? (
<>
@@ -335,14 +335,14 @@ export function SitesPage() {
</button>
<button
onClick={() => openEdit(site.id)}
className="rounded border border-white/10 p-1.5 text-slate-400 hover:text-white hover:bg-white/5 transition"
className="rounded border border-white/10 p-1.5 text-ink-50 hover:text-white hover:bg-white/5 transition"
title="编辑"
>
<Edit3 size={14} />
</button>
<button
onClick={() => handleDelete(site)}
className="rounded border border-white/10 p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-400/10 transition"
className="rounded border border-white/10 p-1.5 text-ink-50 hover:text-red-400 hover:bg-red-400/10 transition"
title="删除"
>
<Trash2 size={14} />
@@ -353,16 +353,16 @@ export function SitesPage() {
{/* 空状态 */}
{!loading && sites.length === 0 && (
<div className="col-span-full py-12 text-center text-slate-400">
<Globe size={40} className="mx-auto mb-3 text-slate-600" />
<div className="col-span-full py-12 text-center text-ink-50">
<Globe size={40} className="mx-auto mb-3 text-sand-400" />
<p>暂无站点</p>
<p className="text-sm mt-1 text-slate-500">点击「添加站点」添加 PT/BT 站点</p>
<p className="text-sm mt-1 text-sand-500">点击「添加站点」添加 PT/BT 站点</p>
</div>
)}
{/* 加载中 */}
{loading && (
<div className="col-span-full py-12 text-center text-slate-400">
<div className="col-span-full py-12 text-center text-ink-50">
<RefreshCw size={24} className="mx-auto mb-3 animate-spin" />
<p>加载中...</p>
</div>
@@ -378,10 +378,10 @@ export function SitesPage() {
>
{/* 标题栏 */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-white">
<h2 className="text-lg font-bold text-ink-600">
{editingId ? '编辑站点' : '添加站点'}
</h2>
<button onClick={closeModal} className="text-slate-400 hover:text-white transition">
<button onClick={closeModal} className="text-ink-50 hover:text-white transition">
<X size={20} />
</button>
</div>
@@ -389,7 +389,7 @@ export function SitesPage() {
<form onSubmit={handleSave} className="space-y-4">
{/* 名称 */}
<div>
<label className="block text-sm text-slate-400 mb-1.5">站点名称 *</label>
<label className="block text-sm text-ink-50 mb-1.5">站点名称 *</label>
<input
required
className="input-base w-full"
@@ -401,7 +401,7 @@ export function SitesPage() {
{/* 地址 */}
<div>
<label className="block text-sm text-slate-400 mb-1.5">站点地址 *</label>
<label className="block text-sm text-ink-50 mb-1.5">站点地址 *</label>
<input
required
className="input-base w-full"
@@ -409,13 +409,13 @@ export function SitesPage() {
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
/>
<p className="text-xs text-slate-500 mt-1">格式: https://www.example.com/</p>
<p className="text-xs text-sand-500 mt-1">格式: https://www.example.com/</p>
</div>
{/* 站点类型 + 状态 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-slate-400 mb-1.5">站点类型</label>
<label className="block text-sm text-ink-50 mb-1.5">站点类型</label>
<select
className="input-base w-full"
value={form.type}
@@ -430,7 +430,7 @@ export function SitesPage() {
</select>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1.5">状态</label>
<label className="block text-sm text-ink-50 mb-1.5">状态</label>
<div className="flex items-center gap-3 h-10">
<button
type="button"
@@ -441,7 +441,7 @@ export function SitesPage() {
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? 'translate-x-4' : 'translate-x-0.5'}`}
/>
</button>
<span className={`text-sm ${form.enabled ? 'text-white' : 'text-slate-500'}`}>
<span className={`text-sm ${form.enabled ? 'text-white' : 'text-sand-500'}`}>
{form.enabled ? '启用' : '停用'}
</span>
</div>
@@ -452,10 +452,10 @@ export function SitesPage() {
{form.type === 'mteam' && (
<div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5">
<div className="text-sm font-medium text-green-400 mb-1">馒头站点配置指南</div>
<div className="text-xs text-slate-400 space-y-1">
<div className="text-xs text-ink-50 space-y-1">
<div><b>站点地址:</b><code className="text-green-300">https://api2.m-team.cc</code></div>
<div><b>认证方式:</b>推荐使用「API Key / Passkey」</div>
<div className="pl-3 text-slate-500">
<div className="pl-3 text-sand-500">
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌<br />
2. 点击「创建令牌」,复制生成的 Token<br />
3. 将 Token 填入下方「令牌」输入框
@@ -466,7 +466,7 @@ export function SitesPage() {
{/* 认证方式 */}
<div>
<label className="block text-sm text-slate-400 mb-2">认证方式</label>
<label className="block text-sm text-ink-50 mb-2">认证方式</label>
<div className="flex gap-2 mb-3">
{[
{ value: 'cookie', label: 'Cookie' },
@@ -479,8 +479,8 @@ export function SitesPage() {
onClick={() => setForm((f) => ({ ...f, auth_type: opt.value }))}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition ${
form.auth_type === opt.value
? 'bg-primary-500 text-white border-primary-500'
: 'border-white/10 text-slate-400 hover:border-primary-500/50'
? 'bg-primary-500 text-ink-600 border-primary-500'
: 'border-white/10 text-ink-50 hover:border-primary-500/50'
}`}
>
{opt.label}
@@ -490,7 +490,7 @@ export function SitesPage() {
{form.auth_type === 'cookie' && (
<div>
<label className="block text-xs text-slate-400 mb-1">Cookie</label>
<label className="block text-xs text-ink-50 mb-1">Cookie</label>
<textarea
rows={3}
className="input-base w-full resize-none text-xs font-mono"
@@ -498,13 +498,13 @@ export function SitesPage() {
value={form.cookie}
onChange={(e) => setForm((f) => ({ ...f, cookie: e.target.value }))}
/>
<p className="text-xs text-slate-500 mt-1">从浏览器开发者工具的请求头中获取 Cookie 值</p>
<p className="text-xs text-sand-500 mt-1">从浏览器开发者工具的请求头中获取 Cookie 值</p>
</div>
)}
{form.auth_type === 'api_key' && (
<div>
<label className="block text-xs text-slate-400 mb-1">令牌(API Key / Passkey)</label>
<label className="block text-xs text-ink-50 mb-1">令牌(API Key / Passkey)</label>
<input
type="password"
className="input-base w-full font-mono text-sm"
@@ -512,7 +512,7 @@ export function SitesPage() {
value={form.api_key}
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
/>
<p className="text-xs text-slate-500 mt-1">
<p className="text-xs text-sand-500 mt-1">
{form.type === 'mteam'
? '馒头:控制台 → 实验室 → 存取令牌'
: '站点的访问 API Key'}
@@ -522,7 +522,7 @@ export function SitesPage() {
{form.auth_type === 'auth_header' && (
<div>
<label className="block text-xs text-slate-400 mb-1">请求头(Authorization)</label>
<label className="block text-xs text-ink-50 mb-1">请求头(Authorization)</label>
<input
className="input-base w-full font-mono text-xs"
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
@@ -534,14 +534,14 @@ export function SitesPage() {
{/* RSS 地址(主表单) */}
<div className="mt-4">
<label className="block text-xs text-slate-400 mb-1">RSS 地址</label>
<label className="block text-xs text-ink-50 mb-1">RSS 地址</label>
<input
className="input-base w-full text-xs font-mono"
placeholder="https://.../torrents/rss?..."
value={form.rss_url}
onChange={(e) => setForm((f) => ({ ...f, rss_url: e.target.value }))}
/>
<p className="text-xs text-slate-500 mt-1">站点 RSS 订阅地址,用于获取最新资源</p>
<p className="text-xs text-sand-500 mt-1">站点 RSS 订阅地址,用于获取最新资源</p>
</div>
</div>
@@ -550,7 +550,7 @@ export function SitesPage() {
<button
type="button"
onClick={() => setAdvancedOpen(!advancedOpen)}
className="flex items-center gap-1 text-xs text-slate-400 hover:text-white transition"
className="flex items-center gap-1 text-xs text-ink-50 hover:text-white transition"
>
{advancedOpen ? '▾' : '▸'} 高级选项
</button>
@@ -559,7 +559,7 @@ export function SitesPage() {
{/* 新增高级字段 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-400 mb-1">User-Agent</label>
<label className="block text-xs text-ink-50 mb-1">User-Agent</label>
<input
className="input-base w-full text-xs"
placeholder="自定义 UA,留空使用默认"
@@ -568,7 +568,7 @@ export function SitesPage() {
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">请求超时 (秒)</label>
<label className="block text-xs text-ink-50 mb-1">请求超时 (秒)</label>
<input
type="number"
className="input-base w-full"
@@ -579,7 +579,7 @@ export function SitesPage() {
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">优先级 (数字越大越优先)</label>
<label className="block text-xs text-ink-50 mb-1">优先级 (数字越大越优先)</label>
<input
type="number"
className="input-base w-full"
@@ -590,7 +590,7 @@ export function SitesPage() {
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">关联下载器</label>
<label className="block text-xs text-ink-50 mb-1">关联下载器</label>
<input
className="input-base w-full text-xs"
placeholder="下载器 ID 或名称"
@@ -609,7 +609,7 @@ export function SitesPage() {
checked={form.use_proxy}
onChange={(e) => setForm((f) => ({ ...f, use_proxy: e.target.checked }))}
/>
<span className="text-xs text-slate-300">使用代理</span>
<span className="text-xs text-ink-100">使用代理</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -618,7 +618,7 @@ export function SitesPage() {
checked={form.rate_limit}
onChange={(e) => setForm((f) => ({ ...f, rate_limit: e.target.checked }))}
/>
<span className="text-xs text-slate-300">启用限流</span>
<span className="text-xs text-ink-100">启用限流</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -627,13 +627,13 @@ export function SitesPage() {
checked={form.browser_emulation}
onChange={(e) => setForm((f) => ({ ...f, browser_emulation: e.target.checked }))}
/>
<span className="text-xs text-slate-300">浏览器模拟</span>
<span className="text-xs text-ink-100">浏览器模拟</span>
</label>
</div>
{/* Extra JSON */}
<div>
<label className="block text-xs text-slate-400 mb-1">Extra 扩展配置 (JSON)</label>
<label className="block text-xs text-ink-50 mb-1">Extra 扩展配置 (JSON)</label>
<textarea
rows={3}
className="input-base w-full resize-none text-xs font-mono"
@@ -652,7 +652,7 @@ export function SitesPage() {
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? 'translate-x-4' : 'translate-x-0.5'}`}
/>
</button>
<span className="text-sm text-slate-400">设为默认站点</span>
<span className="text-sm text-ink-50">设为默认站点</span>
</div>
</div>
)}
@@ -660,7 +660,7 @@ export function SitesPage() {
{/* 按钮 */}
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={closeModal} className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5 transition">
<button type="button" onClick={closeModal} className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5 transition">
取消
</button>
<button
+11 -11
View File
@@ -43,8 +43,8 @@ export function StatsPage() {
}
}, [])
if (loading) return <p className="text-slate-500">加载中…</p>
if (!snap) return <p className="text-slate-500">无法获取统计数据</p>
if (loading) return <p className="text-sand-500">加载中…</p>
if (!snap) return <p className="text-sand-500">无法获取统计数据</p>
const memPct =
snap.hardware.memory_total > 0
@@ -58,8 +58,8 @@ export function StatsPage() {
return (
<div className="space-y-8">
<header>
<h1 className="font-display text-3xl font-bold text-white">运行状态</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">运行状态</h1>
<p className="text-sm text-ink-50">
快照时间:{new Date(snap.generated_at).toLocaleString()}
</p>
</header>
@@ -76,7 +76,7 @@ export function StatsPage() {
</section>
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-white">系统</h2>
<h2 className="font-display text-xl font-semibold text-ink-600">系统</h2>
<div className="glass-panel grid gap-2 text-sm">
<Row label="Go 运行时" value={snap.hardware.go_version} />
<Row label="Goroutines" value={snap.hardware.goroutines.toLocaleString()} />
@@ -93,7 +93,7 @@ export function StatsPage() {
{snap.recently_added.length > 0 && (
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-white">最近入库</h2>
<h2 className="font-display text-xl font-semibold text-ink-600">最近入库</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{snap.recently_added.map((m) => (
<MediaCard key={m.id} media={m} />
@@ -116,12 +116,12 @@ function Tile({
}) {
return (
<div className="glass-panel flex items-center gap-3 !p-4">
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-primary-400">
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
{icon}
</div>
<div>
<p className="text-xs uppercase tracking-wider text-slate-500">{label}</p>
<p className="font-display text-lg font-semibold text-white">{value}</p>
<p className="text-xs uppercase tracking-wider text-sand-500">{label}</p>
<p className="font-display text-lg font-semibold text-ink-600">{value}</p>
</div>
</div>
)
@@ -130,8 +130,8 @@ function Tile({
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between border-b border-white/5 pb-1 text-sm last:border-0">
<span className="text-slate-400">{label}</span>
<span className="font-mono text-white">{value}</span>
<span className="text-ink-50">{label}</span>
<span className="font-mono text-ink-600">{value}</span>
</div>
)
}
+9 -9
View File
@@ -16,8 +16,8 @@ export function StorageConfigPage() {
<Cloud size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">外部存储</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">外部存储</h1>
<p className="text-sm text-ink-50">
配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试
</p>
</div>
@@ -31,8 +31,8 @@ export function StorageConfigPage() {
className={
'border-b-2 px-4 py-2 text-sm uppercase ' +
(active === t
? 'border-primary-400 text-primary-400'
: 'border-transparent text-slate-400 hover:text-white')
? 'border-primary-400 text-brand-500'
: 'border-transparent text-ink-50 hover:text-white')
}
>
{t}
@@ -135,7 +135,7 @@ function StorageForm({ type }: { type: StorageType }) {
if (loading) {
return (
<div className="flex justify-center py-12 text-slate-400">
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)
@@ -145,9 +145,9 @@ function StorageForm({ type }: { type: StorageType }) {
<form onSubmit={onSave} className="glass-panel space-y-4">
{fields.map((f) => (
<label key={f.key} className="block">
<span className="mb-1 block text-sm text-slate-300">
<span className="mb-1 block text-sm text-ink-100">
{f.label}
<span className="ml-2 font-mono text-[10px] text-slate-600">{f.key}</span>
<span className="ml-2 font-mono text-[10px] text-sand-400">{f.key}</span>
</span>
<input
type={f.secret ? 'password' : 'text'}
@@ -158,7 +158,7 @@ function StorageForm({ type }: { type: StorageType }) {
/>
</label>
))}
<label className="flex items-center gap-2 text-sm text-slate-300">
<label className="flex items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
@@ -172,7 +172,7 @@ function StorageForm({ type }: { type: StorageType }) {
type="button"
onClick={onTest}
disabled={testing}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
>
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
{' '}测试
+19 -19
View File
@@ -33,18 +33,18 @@ export function StoragePage() {
.finally(() => setLoading(false))
}, [])
if (loading) return <p className="text-slate-500">加载中…</p>
if (!data) return <p className="text-slate-500">无法获取存储数据</p>
if (loading) return <p className="text-sand-500">加载中…</p>
if (!data) return <p className="text-sand-500">无法获取存储数据</p>
const totalBytes = data.total_bytes || 1
return (
<div className="space-y-8">
<header className="flex items-center gap-3">
<HardDrive className="h-6 w-6 text-primary-400" />
<HardDrive className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-white">存储</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">存储</h1>
<p className="text-sm text-ink-50">
按媒体库和容器格式统计的磁盘占用,数据来自数据库快照(无须实时扫描磁盘)。
</p>
</div>
@@ -57,10 +57,10 @@ export function StoragePage() {
</section>
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-white">按媒体库</h2>
<h2 className="font-display text-xl font-semibold text-ink-600">按媒体库</h2>
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>类型</th>
@@ -74,10 +74,10 @@ export function StoragePage() {
const pct = (l.total_bytes / totalBytes) * 100
return (
<tr key={l.library_id} className="border-t border-white/5">
<td className="py-2 text-white">{l.name}</td>
<td className="text-slate-300">{l.type}</td>
<td className="text-slate-300">{l.media_count}</td>
<td className="text-slate-300">{fmtBytes(l.total_bytes)}</td>
<td className="py-2 text-ink-600">{l.name}</td>
<td className="text-ink-100">{l.type}</td>
<td className="text-ink-100">{l.media_count}</td>
<td className="text-ink-100">{fmtBytes(l.total_bytes)}</td>
<td>
<div className="flex items-center gap-2">
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
@@ -86,7 +86,7 @@ export function StoragePage() {
style={{ width: `${pct.toFixed(1)}%` }}
/>
</div>
<span className="text-xs text-slate-400">{pct.toFixed(1)}%</span>
<span className="text-xs text-ink-50">{pct.toFixed(1)}%</span>
</div>
</td>
</tr>
@@ -98,7 +98,7 @@ export function StoragePage() {
</section>
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-white">按容器格式</h2>
<h2 className="font-display text-xl font-semibold text-ink-600">按容器格式</h2>
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
{data.by_container.map((c) => (
<div
@@ -106,10 +106,10 @@ export function StoragePage() {
className="glass-panel flex items-center justify-between !p-4"
>
<div>
<p className="text-xs uppercase tracking-wider text-slate-500">{c.container}</p>
<p className="font-display text-lg font-semibold text-white">{c.count} 项</p>
<p className="text-xs uppercase tracking-wider text-sand-500">{c.container}</p>
<p className="font-display text-lg font-semibold text-ink-600">{c.count} 项</p>
</div>
<p className="text-sm text-slate-300">{fmtBytes(c.bytes)}</p>
<p className="text-sm text-ink-100">{fmtBytes(c.bytes)}</p>
</div>
))}
</div>
@@ -129,12 +129,12 @@ function Tile({
}) {
return (
<div className="glass-panel flex items-center gap-3 !p-4">
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-primary-400">
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
{icon}
</div>
<div>
<p className="text-xs uppercase tracking-wider text-slate-500">{label}</p>
<p className="font-display text-lg font-semibold text-white">{value}</p>
<p className="text-xs uppercase tracking-wider text-sand-500">{label}</p>
<p className="font-display text-lg font-semibold text-ink-600">{value}</p>
</div>
</div>
)
+8 -8
View File
@@ -121,8 +121,8 @@ export function StrmPage() {
<LinkIcon size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">STRM 管理</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">STRM 管理</h1>
<p className="text-sm text-ink-50">
将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库
</p>
</div>
@@ -130,7 +130,7 @@ export function StrmPage() {
{/* Import a new STRM-only entry. */}
<section className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-white">导入 STRM 条目</h2>
<h2 className="font-display text-lg font-semibold text-ink-600">导入 STRM 条目</h2>
<form onSubmit={onImport} className="grid gap-3 md:grid-cols-4">
<select
required
@@ -165,14 +165,14 @@ export function StrmPage() {
<Plus size={16} /> {importing ? '导入中…' : '导入'}
</button>
</form>
<p className="text-xs text-slate-500">
<p className="text-xs text-sand-500">
导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。
</p>
</section>
{/* Attach / detach STRM URL on existing media. */}
<section className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-white">附加 STRM URL 到已有媒体</h2>
<h2 className="font-display text-lg font-semibold text-ink-600">附加 STRM URL 到已有媒体</h2>
<form onSubmit={doSearch} className="flex gap-2">
<input
className="input-base flex-1"
@@ -196,8 +196,8 @@ export function StrmPage() {
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium text-white">{m.title}</div>
<div className="truncate text-xs text-slate-400">
<div className="truncate font-medium text-ink-600">{m.title}</div>
<div className="truncate text-xs text-ink-50">
{m.year > 0 && `${m.year} · `}
{m.container || '本地文件'}
</div>
@@ -240,7 +240,7 @@ export function StrmPage() {
)}
{!searching && query && results.length === 0 && (
<p className="text-sm text-slate-400">未找到匹配的媒体。</p>
<p className="text-sm text-ink-50">未找到匹配的媒体。</p>
)}
</section>
</div>
+10 -10
View File
@@ -40,8 +40,8 @@ export function SubscriptionsPage() {
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">RSS 订阅</h1>
<p className="text-sm text-slate-400">
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
<p className="text-sm text-ink-50">
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列。
</p>
@@ -71,13 +71,13 @@ export function SubscriptionsPage() {
</button>
</form>
{loading && <p className="text-slate-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-slate-400">暂无订阅。</p>}
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
{items.length > 0 && (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>RSS</th>
@@ -89,17 +89,17 @@ export function SubscriptionsPage() {
<tbody>
{items.map((s) => (
<tr key={s.id} className="border-t border-white/5">
<td className="py-2 text-white">{s.name}</td>
<td className="max-w-md truncate text-slate-300" title={s.feed_url}>
<td className="py-2 text-ink-600">{s.name}</td>
<td className="max-w-md truncate text-ink-100" title={s.feed_url}>
{s.feed_url}
</td>
<td className="text-slate-300">{s.filter || '—'}</td>
<td className="text-slate-500">
<td className="text-ink-100">{s.filter || '—'}</td>
<td className="text-sand-500">
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
const r = await subscriptionsAPI.runNow(s.id)
toast.success(`已加入 ${r.queued} 项`)
+16 -16
View File
@@ -34,23 +34,23 @@ export function TasksPage() {
}
}, [])
if (!snap) return <p className="text-slate-500">加载中…</p>
if (!snap) return <p className="text-sand-500">加载中…</p>
const torrents = snap.torrents ?? []
return (
<div className="space-y-8">
<header className="flex items-center gap-3">
<Activity className="h-6 w-6 text-primary-400" />
<h1 className="font-display text-3xl font-bold text-white">实时任务</h1>
<Activity className="h-6 w-6 text-brand-500" />
<h1 className="font-display text-3xl font-bold text-ink-600">实时任务</h1>
</header>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">转码任务</h2>
{snap.transcodes.length === 0 && <p className="text-slate-500">暂无运行中转码。</p>}
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">转码任务</h2>
{snap.transcodes.length === 0 && <p className="text-sand-500">暂无运行中转码。</p>}
{snap.transcodes.length > 0 && (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">媒体 ID</th>
<th>编码器</th>
@@ -61,9 +61,9 @@ export function TasksPage() {
<tbody>
{snap.transcodes.map((t) => (
<tr key={t.media_id} className="border-t border-white/5">
<td className="py-2 font-mono text-xs text-white">{t.media_id}</td>
<td className="text-slate-300">{t.encoder || 'libx264'}</td>
<td className="text-slate-300">{new Date(t.started_at).toLocaleTimeString()}</td>
<td className="py-2 font-mono text-xs text-ink-600">{t.media_id}</td>
<td className="text-ink-100">{t.encoder || 'libx264'}</td>
<td className="text-ink-100">{new Date(t.started_at).toLocaleTimeString()}</td>
<td>
{t.playlist_ok ? (
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
@@ -83,11 +83,11 @@ export function TasksPage() {
</section>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">下载任务</h2>
{torrents.length === 0 && <p className="text-slate-500">暂无运行中下载。</p>}
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">下载任务</h2>
{torrents.length === 0 && <p className="text-sand-500">暂无运行中下载。</p>}
{torrents.length > 0 && (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>状态</th>
@@ -98,11 +98,11 @@ export function TasksPage() {
<tbody>
{torrents.map((t) => (
<tr key={t.hash} className="border-t border-white/5 align-top">
<td className="max-w-md truncate py-2 text-white" title={t.name}>
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
{t.name}
</td>
<td className="text-slate-300">{t.state}</td>
<td className="text-slate-300">
<td className="text-ink-100">{t.state}</td>
<td className="text-ink-100">
<div className="flex items-center gap-2">
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
<div
@@ -113,7 +113,7 @@ export function TasksPage() {
{(t.progress * 100).toFixed(1)}%
</div>
</td>
<td className="text-slate-300">{fmtBytes(t.size)}</td>
<td className="text-ink-100">{fmtBytes(t.size)}</td>
</tr>
))}
</tbody>
+14 -14
View File
@@ -24,8 +24,8 @@ export function ToolsPage() {
<Wrench size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">运维工具</h1>
<p className="text-sm text-slate-400">整理媒体文件 · 测试通知渠道</p>
<h1 className="font-display text-3xl font-bold text-ink-600">运维工具</h1>
<p className="text-sm text-ink-50">整理媒体文件 · 测试通知渠道</p>
</div>
</div>
@@ -118,22 +118,22 @@ function OrganizePanel() {
return (
<section className="glass-panel space-y-5">
<div className="flex items-center gap-2">
<FolderCog size={18} className="text-primary-400" />
<h2 className="font-display text-lg font-semibold text-white">整理 &amp; 重命名</h2>
<FolderCog size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">整理 &amp; 重命名</h2>
</div>
<p className="text-xs text-slate-500">
<p className="text-xs text-sand-500">
将媒体文件按 <code className="rounded bg-white/5 px-1">媒体库/年份/标题 (年份)/标题.ext</code>{' '}
的规范布局移动并重命名,确保已先完成刮削。
</p>
{!loadingSettings && (
<div className="flex items-center gap-2 text-xs">
<Info size={14} className={smartClassify ? 'text-green-400' : 'text-slate-500'} />
<span className={smartClassify ? 'text-green-400' : 'text-slate-500'}>
<Info size={14} className={smartClassify ? 'text-green-400' : 'text-sand-500'} />
<span className={smartClassify ? 'text-green-400' : 'text-sand-500'}>
智能分类:{smartClassify ? '已启用' : '未启用'}
</span>
{smartClassify && (
<span className="text-slate-500">(将根据元数据自动分类到子目录)</span>
<span className="text-sand-500">(将根据元数据自动分类到子目录)</span>
)}
</div>
)}
@@ -181,8 +181,8 @@ function OrganizePanel() {
className="flex items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/5 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm text-white">{m.title}</div>
<div className="truncate text-xs text-slate-400">{m.path}</div>
<div className="truncate text-sm text-ink-600">{m.title}</div>
<div className="truncate text-xs text-ink-50">{m.path}</div>
</div>
<button
onClick={() => onOrganizeOne(m)}
@@ -202,7 +202,7 @@ function OrganizePanel() {
)}
{!searching && query && results.length === 0 && (
<p className="mt-3 text-sm text-slate-400">未找到匹配的媒体。</p>
<p className="mt-3 text-sm text-ink-50">未找到匹配的媒体。</p>
)}
</div>
</section>
@@ -235,9 +235,9 @@ function NotifyPanel() {
<section className="glass-panel space-y-4">
<div className="flex items-center gap-2">
<Bell size={18} className="text-amber-300" />
<h2 className="font-display text-lg font-semibold text-white">通知渠道测试</h2>
<h2 className="font-display text-lg font-semibold text-ink-600">通知渠道测试</h2>
</div>
<p className="text-xs text-slate-500">
<p className="text-xs text-sand-500">
会向所有已配置的通知渠道(Telegram / Bark / Webhook 等)发送一条测试消息。
</p>
<form onSubmit={onSend} className="space-y-3">
@@ -270,7 +270,7 @@ function NotifyPanel() {
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
+6 -6
View File
@@ -29,13 +29,13 @@ export function WatchHistoryPage() {
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Clock className="h-6 w-6 text-primary-400" />
<h1 className="font-display text-3xl font-bold text-white">观看历史</h1>
<Clock className="h-6 w-6 text-brand-500" />
<h1 className="font-display text-3xl font-bold text-ink-600">观看历史</h1>
</header>
{loading && <p className="text-slate-500">加载中…</p>}
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && (
<p className="text-slate-400">暂无观看记录。</p>
<p className="text-ink-50">暂无观看记录。</p>
)}
<div className="space-y-3">
@@ -62,11 +62,11 @@ export function WatchHistoryPage() {
<div className="flex-1 space-y-1">
<Link
to={`/media/${m.id}`}
className="font-medium text-white transition hover:text-primary-400"
className="font-medium text-ink-600 transition hover:text-brand-500"
>
{m.title}
</Link>
<div className="flex items-center gap-3 text-xs text-slate-400">
<div className="flex items-center gap-3 text-xs text-ink-50">
<span>{fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)}</span>
<span>{new Date(h.watched_at).toLocaleString()}</span>
{h.completed && (
+90 -76
View File
@@ -1,4 +1,4 @@
/** @type {import('tailwindcss').Config} */
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
@@ -9,73 +9,100 @@ export default {
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
},
colors: {
// ── New Warm Industrial palette ──
// ── Brand: Luxurious Editorial Gold ──
brand: {
DEFAULT: '#c9954a',
50: '#fdf8f0',
100: '#f9edda',
200: '#f2d8b0',
300: '#e8bc7d',
400: '#dca45c',
50: '#fdf9f4',
100: '#fbeedf',
200: '#f6dbb9',
300: '#efc187',
400: '#e5a153',
500: '#c9954a',
600: '#b07a38',
700: '#936031',
800: '#794e2d',
900: '#654128',
950: '#3a2214',
600: '#b07f3c',
700: '#926630',
800: '#755027',
900: '#5e4121',
950: '#332110',
},
// ── Sage: Muted Teal Accent ──
sage: {
DEFAULT: '#7a9a8a',
50: '#f3f7f4',
100: '#e4ece6',
200: '#c9d9ce',
300: '#a6bfad',
400: '#7a9a8a',
500: '#5e7e6e',
600: '#496558',
700: '#3c5247',
800: '#334339',
900: '#2c3831',
950: '#151f19',
DEFAULT: '#6b8275',
50: '#f4f6f5',
100: '#e5eae7',
200: '#ccd7d1',
300: '#a3b8ad',
400: '#6b8275',
500: '#54685d',
600: '#425149',
700: '#35413a',
800: '#2d3531',
950: '#141a17',
},
// ── Ink: High-Contrast Graphite / Charcoal (Elegant Light-Theme Text) ──
ink: {
DEFAULT: '#111827',
50: '#6b7280', // Elegant secondary text
100: '#4b5563', // Main body text
200: '#374151', // Dark text
300: '#1f2937',
400: '#111827',
500: '#111827',
600: '#111827', // Headers
700: '#030712',
800: '#000000',
900: '#000000',
},
// ── Sand: High-End Swiss Slate Grays ──
sand: {
DEFAULT: '#f9fafb',
50: '#ffffff', // Card base
100: '#f9fafb', // System body background
200: '#f3f4f6', // Panel base / hover background
300: '#e5e7eb', // Thin premium borders
400: '#d1d5db', // Mid-lines
500: '#9ca3af', // Muted placeholders
600: '#6b7280',
700: '#4b5563',
800: '#374151',
900: '#1f2937',
},
// ── Surface: Compatibility ──
surface: {
DEFAULT: '#1a1a18',
50: '#2e2e2a',
100: '#2a2a26',
200: '#242420',
300: '#1e1e1b',
400: '#1a1a18',
500: '#161614',
600: '#111110',
// Backward-compat (old 700-950 → warm charcoal)
700: '#242420',
800: '#1a1a18',
900: '#111110',
950: '#0d0d0c',
DEFAULT: '#ffffff',
50: '#ffffff',
100: '#f9fafb',
200: '#f3f4f6',
300: '#e5e7eb',
400: '#ffffff',
500: '#ffffff',
600: '#1f2937',
700: '#111827',
800: '#030712',
900: '#000000',
950: '#000000',
},
cream: {
DEFAULT: '#e8e4d9',
50: '#faf8f4',
100: '#f3f0e8',
200: '#e8e4d9',
300: '#c4bfb5',
400: '#a09b92',
500: '#7d7871',
600: '#605c56',
700: '#4a4742',
800: '#363330',
900: '#242220',
},
// ── Backward-compat aliases (old class names → new warm colors) ──
// Backward compatibility colors mapped to slate-light
primary: {
400: '#dca45c', // was cyan → now brand-400
500: '#c9954a', // was cyan → now brand-500
600: '#b07a38', // was cyan → now brand-600
400: '#e5a153',
500: '#c9954a',
600: '#b07f3c',
},
accent: {
400: '#7a9a8a', // was purple → now sage-400
500: '#5e7e6e', // was purple → now sage-500
400: '#6b8275',
500: '#54685d',
},
cream: {
DEFAULT: '#faf9f6',
50: '#ffffff',
100: '#faf9f6',
200: '#f4f3ee',
300: '#e8e6dc',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#1f2937',
800: '#111827',
900: '#000000',
},
},
fontSize: {
@@ -88,26 +115,13 @@ export default {
transitionDuration: {
'400': '400ms',
},
animation: {
'slide-in-left': 'slideInLeft 0.3s ease-out',
'fade-in': 'fadeIn 0.4s ease-out',
'scale-in': 'scaleIn 0.3s ease-out',
},
keyframes: {
slideInLeft: {
'0%': { opacity: '0', transform: 'translateX(-8px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.96)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
boxShadow: {
'card': '0 1px 3px rgba(0,0,0,0.02), 0 1px 2px rgba(0,0,0,0.01)',
'card-hover': '0 10px 30px rgba(0,0,0,0.04), 0 2px 8px rgba(0,0,0,0.02)',
'elevated': '0 20px 50px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.03)',
'sidebar': '1px 0 0 rgba(0,0,0,0.03)',
},
},
},
plugins: [],
}
}