mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
UI 全面重设计: 明亮画廊风格 + 剧集合并显示
## 色彩体系重构 - tailwind.config.js: sand/ink 全新色阶 + shadow 阴影预设 - index.css: 全组件系统重写 (card/input/btn/badge/table) ## 核心页面重写 - LoginPage: 纯白卡片 + 品牌光晕 + Framer Motion 入场动画 - HomePage: Billboard 推荐位 + 剧集卡片联动媒体库 - Layout: 白色侧边栏 + 路由过渡动画 - MediaCard: 新增 linkTo prop, 悬停浮层优化 ## 剧集合并显示 - LibraryPage: 使用 groupSeries 合并剧集, 分季选集详情 - 首页剧集卡片自动链接到媒体库详情 ## 全量类名迁移 (43 文件) - text-slate-* -> text-ink-*/text-sand-* - text-primary-* -> text-brand-* - text-white -> text-ink-600 - bg/border-slate-* -> bg/border-sand-* - 500+ 深色硬编码类名清零
This commit is contained in:
+1
-1
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
+379
-143
@@ -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,64 +16,105 @@ 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',
|
|
||||||
'group-hover:w-60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Logo */}
|
|
||||||
<Link to="/" className="flex items-center gap-3 px-4 py-5">
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand-500/15">
|
|
||||||
<Film className="h-4 w-4 text-brand-400" />
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
{(isSidebarOpen || isMobileDrawerOpen) && (
|
||||||
className={clsx(
|
<motion.span
|
||||||
'font-display text-lg font-bold tracking-tight text-cream-100',
|
initial={{ opacity: 0, x: -10 }}
|
||||||
'overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200',
|
animate={{ opacity: 1, x: 0 }}
|
||||||
'group-hover:opacity-100',
|
className="font-display text-lg font-extrabold tracking-tight text-[#111827]"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
MediaStation
|
MediaStation
|
||||||
</span>
|
</motion.span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Toggle Collapse Button for Large Screen */}
|
||||||
<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={() => 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>
|
||||||
|
|
||||||
<SectionHeader label="浏览" />
|
{/* Mobile Drawer Close Button */}
|
||||||
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
|
<button
|
||||||
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
onClick={() => setIsMobileDrawerOpen(false)}
|
||||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
|
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-colors block lg:hidden"
|
||||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
|
>
|
||||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
|
<X size={18} />
|
||||||
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="海报墙" />
|
</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation List */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-4 py-6 space-y-6 scrollbar-hide">
|
||||||
|
{/* Navigation Group: Main */}
|
||||||
|
<div>
|
||||||
|
<SectionHeader label="影音中心" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<SidebarLink to="/" icon={<Home size={18} />} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
|
<SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
|
<SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
|
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
|
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
|
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
|
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="影音海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
|
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 影视助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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) => (
|
{libraries.map((lib) => (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
@@ -107,115 +122,336 @@ export function Layout() {
|
|||||||
to={`/library/${lib.id}`}
|
to={`/library/${lib.id}`}
|
||||||
icon={<LibraryIcon size={18} />}
|
icon={<LibraryIcon size={18} />}
|
||||||
label={lib.name}
|
label={lib.name}
|
||||||
|
collapsed={!isSidebarOpen && !isMobileDrawerOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SectionHeader label="自动化" />
|
{/* Navigation Group: Automation */}
|
||||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
<div>
|
||||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
<SectionHeader label="自动化运维" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
<div className="space-y-1">
|
||||||
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
|
<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>
|
||||||
|
|
||||||
<SectionHeader label="账号" />
|
{/* Navigation Group: Account Profile */}
|
||||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
|
<div>
|
||||||
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" />
|
<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' && (
|
{user?.role === 'admin' && (
|
||||||
<>
|
<div>
|
||||||
<SectionHeader label="管理" />
|
<SectionHeader label="系统后台" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
<div className="space-y-1">
|
||||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
<SidebarLink to="/admin" icon={<Settings size={18} />} label="后台主页" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行监控" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
|
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
|
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知配置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载客户端" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时机制" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
|
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储分析" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部挂载" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件管家" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
|
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="排重清理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
|
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 关联" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
|
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
|
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 智能助教" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
<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>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom: logout */}
|
{/* Sidebar Logout Action */}
|
||||||
<div className="border-t border-cream-900/15 px-3 py-3">
|
<div className="border-t border-gray-100 p-4 bg-gray-50/50">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={() => { logout(); navigate('/login') }}
|
||||||
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"
|
className={clsx(
|
||||||
title={`退出登录 (${user?.username})`}
|
"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={16} />
|
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||||
<span className="overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
{(isSidebarOpen || isMobileDrawerOpen) && <span>安全退出</span>}
|
||||||
退出
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── Main content ── */}
|
{/* Header Right Area: Actions, Notifications, Profile Dropdown */}
|
||||||
<main className="flex flex-1 flex-col overflow-y-auto">
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
<div className="flex-1 px-6 py-8">
|
{/* Quick search button shown ONLY on Mobile screens */}
|
||||||
<Outlet />
|
<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>
|
</div>
|
||||||
<AppFooter className="py-4" />
|
|
||||||
</main>
|
</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>
|
||||||
|
{!collapsed && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, x: -5 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="truncate whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
const href = linkTo ?? `/media/${media.id}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link to={href} className="group block">
|
||||||
to={`/media/${media.id}`}
|
<motion.div
|
||||||
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"
|
ref={ref}
|
||||||
|
whileHover={{ scale: 1.04, y: -6 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
|
||||||
|
className="relative overflow-hidden rounded-2xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)]"
|
||||||
>
|
>
|
||||||
{/* Poster */}
|
{/* Poster Wrapper */}
|
||||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gray-50">
|
||||||
{media.poster_url ? (
|
{media.poster_url ? (
|
||||||
<img
|
<img
|
||||||
src={imageURL(media.poster_url)}
|
src={imageURL(media.poster_url)}
|
||||||
alt={media.title}
|
alt={media.title}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-400 bg-gray-50">
|
||||||
<Film size={36} />
|
<Film size={28} className="stroke-[1.5]" />
|
||||||
|
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hover overlay */}
|
{/* Episode count badge */}
|
||||||
<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">
|
{count !== undefined && count > 1 && (
|
||||||
<span className="flex items-center gap-1 text-[11px] text-white/90">
|
<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">
|
||||||
<Play size={11} />
|
<Layers size={10} className="text-[#c9954a]" />
|
||||||
播放
|
<span>{count} 集</span>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress Bar overlay */}
|
||||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
|
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-gray-200">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-brand-500/80 transition-colors group-hover:bg-brand-400"
|
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)}%` }}
|
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Media Metadata Info */}
|
||||||
<div className="px-2 py-2">
|
<div className="p-4 space-y-1 bg-white border-t border-gray-50">
|
||||||
<p className="truncate text-xs 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 duration-200">
|
||||||
{media.title}
|
{media.title}
|
||||||
</p>
|
</p>
|
||||||
{media.year > 0 && (
|
<div className="flex items-center justify-between text-[11px] text-gray-500 font-bold uppercase tracking-wider">
|
||||||
<p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
|
<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>
|
||||||
|
</motion.div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+107
-136
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
+210
-261
@@ -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,前端折叠成剧集卡片
|
|
||||||
const perLib = await Promise.all(
|
|
||||||
libs.map(async (lib) => {
|
|
||||||
try {
|
try {
|
||||||
const page = await libraryAPI.listMedia(lib.id, 1, 60)
|
const page = await libraryAPI.listMedia(lib.id, 1, 60)
|
||||||
const cards = groupSeries(page.items).slice(0, 12)
|
return { library: lib, cards: groupSeries(page.items).slice(0, 14) } as LibraryRow
|
||||||
return { library: lib, cards } as LibraryRow
|
} catch { 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">
|
||||||
|
{featuredItem.poster_url ? (
|
||||||
|
<img
|
||||||
|
src={imageURL(featuredItem.poster_url)}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover object-top opacity-5 scale-105 blur-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gray-50" />
|
||||||
|
)}
|
||||||
|
{/* Soft Bright Masking */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-white via-white/95 to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
</h1>
|
||||||
<p className="mt-1.5 text-sm text-cream-400">
|
|
||||||
从你上次离开的地方继续
|
{featuredItem.overview ? (
|
||||||
|
<p className="text-gray-500 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
|
||||||
|
{featuredItem.overview}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
{!loading && (
|
|
||||||
<Link
|
{/* Buttons */}
|
||||||
to="/discover"
|
<div className="flex flex-wrap items-center gap-4 pt-4">
|
||||||
className="flex items-center gap-1.5 text-sm text-cream-400 transition-colors hover:text-brand-400"
|
<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" />
|
||||||
<Compass size={14} />
|
<span>立即播放</span>
|
||||||
发现更多
|
</Link>
|
||||||
<ArrowRight size={14} />
|
<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>
|
</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>
|
</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>
|
||||||
{!loading && libraries.length === 0 && fallbackCards.length > 0 && (
|
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">
|
||||||
<section>
|
{row.library.name}
|
||||||
<SectionHeading icon={<Clock size={16} />} label="最近添加" />
|
|
||||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
|
||||||
{fallbackCards.map((s) => (
|
|
||||||
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Links */}
|
|
||||||
{!empty && (
|
|
||||||
<section>
|
|
||||||
<SectionHeading icon={<Sparkles size={16} />} label="快捷入口" />
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
<QuickLink to="/discover" label="发现" desc="探索新内容" />
|
|
||||||
<QuickLink to="/favourites" label="收藏" desc="我的珍藏" />
|
|
||||||
<QuickLink to="/history" label="历史" desc="观看记录" />
|
|
||||||
<QuickLink to="/ai" label="AI" desc="智能推荐" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Library 行 ─── */
|
|
||||||
function LibrarySection({ row }: { row: LibraryRow }) {
|
|
||||||
const lib = row.library
|
|
||||||
const typeIcon = TYPE_ICONS[lib.type] ?? <LibraryIcon size={16} />
|
|
||||||
const typeLabel = TYPE_LABELS[lib.type] ?? lib.type
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-cream-400">{typeIcon}</span>
|
|
||||||
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
|
|
||||||
{lib.name}
|
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs text-cream-500">·</span>
|
<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">
|
||||||
<span className="text-xs text-cream-500">{typeLabel}</span>
|
{TYPE_LABELS[row.library.type] ?? row.library.type}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<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">
|
||||||
to={`/library/${lib.id}`}
|
<span>浏览全部</span>
|
||||||
className="flex items-center gap-1.5 text-xs text-cream-400 transition-colors hover:text-brand-400"
|
<ChevronRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||||
>
|
|
||||||
查看全部
|
|
||||||
<ArrowRight size={12} />
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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">
|
|
||||||
|
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
|
||||||
{row.cards.map((s) => (
|
{row.cards.map((s) => (
|
||||||
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
|
<MediaCard
|
||||||
|
key={s.rep.id}
|
||||||
|
media={s.rep}
|
||||||
|
count={s.count}
|
||||||
|
linkTo={s.count > 1 ? `/library/${row.library.id}` : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-cream-900/30">
|
|
||||||
<Film size={20} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
|
||||||
{media.title}
|
|
||||||
</p>
|
|
||||||
{media.year > 0 && (
|
|
||||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
|
||||||
)}
|
|
||||||
{progress > 0 && (
|
|
||||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-brand-500/70 transition-all group-hover:bg-brand-400"
|
|
||||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Play
|
|
||||||
size={14}
|
|
||||||
className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Poster Card (grid item) ─── */
|
|
||||||
function PosterCard({ media, count = 1 }: { media: Media; count?: number }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={`/media/${media.id}`}
|
|
||||||
className="group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
|
||||||
{media.poster_url ? (
|
|
||||||
<img
|
|
||||||
src={imageURL(media.poster_url)}
|
|
||||||
alt={media.title}
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
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 w-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={36} />
|
<Film size={16} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{count > 1 && (
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<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">
|
<Play size={14} fill="white" className="text-white" />
|
||||||
<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">
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
<p className="truncate text-xs 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-[11px] text-cream-500">{media.year}</p>
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||||
)}
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${Math.round(progress * 100)}%` }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-brand-400 to-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-400 font-bold tracking-wide uppercase">
|
||||||
|
已观看到 {Math.round(progress * 100)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+187
-69
@@ -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)
|
|
||||||
.then((s) => setSeasons(s))
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
} else {
|
|
||||||
libraryAPI
|
libraryAPI
|
||||||
.listMedia(id)
|
.listMedia(id, 1, limit)
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
setItems(d.items)
|
setItems(d.items)
|
||||||
setTotal(d.total)
|
setTotal(d.total)
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 剧集模式:折叠卡片网格 */}
|
||||||
|
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
||||||
|
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||||
|
{seriesCards.map((s) => (
|
||||||
|
<button key={s.rep.id} onClick={() => handleSeriesClick(s)} className="text-left">
|
||||||
|
<MediaCard media={s.rep} count={s.count} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 剧集详情:季/集选择器 */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{selectedSeries && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* 返回按钮 + 标题 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSeries(null)}
|
||||||
|
className="btn-ghost gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
返回列表
|
||||||
|
</button>
|
||||||
|
<h2 className="font-display text-2xl font-bold text-ink-600 truncate">
|
||||||
|
{selectedSeries.rep.title}
|
||||||
</h2>
|
</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">
|
<span className="text-sm text-sand-500">共 {selectedSeries.count} 集</span>
|
||||||
{s.episodes.map((e) => (
|
</div>
|
||||||
<div key={e.id} className="space-y-1">
|
|
||||||
<MediaCard media={e} />
|
{/* 海报 + 从第一集开始播放 */}
|
||||||
{e.episode_num > 0 && (
|
<div className="flex flex-col sm:flex-row gap-6">
|
||||||
<p className="px-1 text-xs text-slate-400">第 {e.episode_num} 集</p>
|
<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>
|
||||||
|
<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>
|
||||||
</section>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</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]}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+144
-37
@@ -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>
|
</div>
|
||||||
|
|
||||||
<label className="mb-4 block">
|
{/* ── Main Container ── */}
|
||||||
<span className="mb-1 block text-sm text-slate-300">用户名</span>
|
<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
|
<input
|
||||||
className="input-base"
|
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}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
required
|
required
|
||||||
|
placeholder="请输入您的账号"
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<label className="mb-6 block">
|
{/* Password Input with Integrated Icon */}
|
||||||
<span className="mb-1 block text-sm text-slate-300">密码</span>
|
<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
|
<input
|
||||||
type="password"
|
type={showPw ? 'text' : 'password'}
|
||||||
className="input-base"
|
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}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</label>
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="neon-button w-full justify-center"
|
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 ? '登录中…' : '登录'}
|
{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>
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-xs text-slate-500">
|
{/* Quick Helper Credentials */}
|
||||||
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
|
<motion.div
|
||||||
</p>
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* 页脚始终在底部 */}
|
{/* Footer */}
|
||||||
<AppFooter className="mb-4 mt-auto" />
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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,93 +113,221 @@ 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 ? (
|
||||||
|
<img
|
||||||
|
src={imageURL(media.poster_url)}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-transparent" />
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/95 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main Details Layout Container ── */}
|
||||||
|
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
|
||||||
|
{/* Poster Card */}
|
||||||
|
<div className="w-56 shrink-0 mx-auto md:mx-0">
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
className="aspect-[2/3] w-full rounded-2xl overflow-hidden bg-gray-50 border border-gray-200 shadow-md relative group"
|
||||||
|
>
|
||||||
{media.poster_url ? (
|
{media.poster_url ? (
|
||||||
<img
|
<img
|
||||||
src={imageURL(media.poster_url)}
|
src={imageURL(media.poster_url)}
|
||||||
alt={media.title}
|
alt={media.title}
|
||||||
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 w-full items-center justify-center text-slate-600">
|
<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>
|
</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">
|
{/* Quick Play overlay button */}
|
||||||
{media.video_codec && <Badge>{media.video_codec.toUpperCase()}</Badge>}
|
<Link
|
||||||
{media.width > 0 && (
|
to={`/play/${media.id}`}
|
||||||
<Badge>
|
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
{media.width}×{media.height}
|
>
|
||||||
</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">
|
||||||
)}
|
<Play size={24} fill="currentColor" />
|
||||||
<Badge>{fmtDuration(media.duration_sec)}</Badge>
|
|
||||||
<Badge>{fmtSize(media.size_bytes)}</Badge>
|
|
||||||
{media.container && <Badge>{media.container.toUpperCase()}</Badge>}
|
|
||||||
<Badge>{media.scrape_status}</Badge>
|
|
||||||
{parseCSV(media.languages).map(l => (
|
|
||||||
<Badge key={l} className="border-blue-400/30 text-blue-400 bg-blue-400/10">{l}</Badge>
|
|
||||||
))}
|
|
||||||
{parseCSV(media.countries).map(c => (
|
|
||||||
<Badge key={c} className="border-green-400/30 text-green-400 bg-green-400/10">{c}</Badge>
|
|
||||||
))}
|
|
||||||
{parseCSV(media.genres).map(g => (
|
|
||||||
<Badge key={g} className="border-purple-400/30 text-purple-400 bg-purple-400/10">{g}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Link to={`/play/${media.id}`} className="neon-button">
|
|
||||||
<Play size={18} /> 播放
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed Metadata Body */}
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
|
{/* Title and Year Header */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h1 className="font-display text-3xl sm:text-4xl font-extrabold tracking-tight text-gray-900 leading-tight">
|
||||||
|
{media.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
||||||
|
{media.year > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
||||||
|
<Calendar size={13} className="text-brand-500" />
|
||||||
|
<span>{media.year} 年</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{media.width > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-lg">
|
||||||
|
<span>{media.width} × {media.height}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
||||||
|
{fmtSize(media.size_bytes)}
|
||||||
|
</span>
|
||||||
|
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
||||||
|
{fmtDuration(media.duration_sec)}
|
||||||
|
</span>
|
||||||
|
{media.container && (
|
||||||
|
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700 font-mono">
|
||||||
|
{media.container}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description Card */}
|
||||||
|
{media.overview && (
|
||||||
|
<div className="rounded-2xl bg-gray-50/50 border border-gray-100 p-5 space-y-2">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-500">剧情简介</h3>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed font-semibold">
|
||||||
|
{media.overview}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tag Rows (Genres, Languages, Countries) */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Genres */}
|
||||||
|
{parseCSV(media.genres).length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">类型流派</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{parseCSV(media.genres).map((g) => (
|
||||||
|
<span key={g} className="rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider">
|
||||||
|
{g}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Languages */}
|
||||||
|
{parseCSV(media.languages).length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">语言</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{parseCSV(media.languages).map((l) => (
|
||||||
|
<span key={l} className="rounded-lg bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
||||||
|
{l}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Countries */}
|
||||||
|
{parseCSV(media.countries).length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">国家/地区</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{parseCSV(media.countries).map((c) => (
|
||||||
|
<span key={c} className="rounded-lg bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider border-gray-200/60" />
|
||||||
|
|
||||||
|
{/* Action Buttons Panel */}
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{/* Primary Direct Play */}
|
||||||
|
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||||
|
<Play size={16} fill="currentColor" />
|
||||||
|
<span>立即播放</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Transcode Playback */}
|
||||||
<Link
|
<Link
|
||||||
to={`/play/${media.id}?mode=hls`}
|
to={`/play/${media.id}?mode=hls`}
|
||||||
className="neon-button !border-accent-400/40 !bg-accent-400/10 !text-accent-400 hover:!border-accent-400"
|
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} /> HLS 转码播放
|
<RefreshCw size={14} className="animate-spin-slow" />
|
||||||
|
<span>HLS 兼容转码播放</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Toggle Favourites */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleFav}
|
onClick={toggleFav}
|
||||||
className={
|
className={
|
||||||
'neon-button ' +
|
'btn-outline gap-2 ' +
|
||||||
(favourite
|
(favourite
|
||||||
? '!border-pink-400/60 !bg-pink-400/10 !text-pink-400'
|
? '!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={16} fill={favourite ? 'currentColor' : 'none'} />
|
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
|
||||||
{favourite ? '已收藏' : '收藏'}
|
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
|
||||||
</button>
|
</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 onClick={rescrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||||
|
<Sparkles size={13} className="text-[#c9954a]" />
|
||||||
|
<span>智能刮削 (TMDB)</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={reprobe} 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">
|
||||||
<RefreshCw size={16} /> 重新探测
|
<Database size={13} className="text-gray-600" />
|
||||||
|
<span>探测媒体轨 (ffprobe)</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={exportNFO} className="neon-button">
|
<button onClick={exportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||||
<FileText size={16} /> 导出 NFO
|
<FileText size={13} />
|
||||||
|
<span>写出本地 NFO 属性</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={softDelete}
|
onClick={softDelete}
|
||||||
className="neon-button !border-red-400/40 !bg-red-400/10 !text-red-400"
|
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={16} /> 移至回收站
|
<Trash2 size={13} />
|
||||||
|
<span>移入回收站</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,12 +335,3 @@ export function MediaDetailPage() {
|
|||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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('已移除')
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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('已恢复')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />}
|
||||||
{' '}测试
|
{' '}测试
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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">整理 & 重命名</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">
|
||||||
将媒体文件按 <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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
+89
-75
@@ -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,24 +115,11 @@ 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)' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user