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