mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
refactor: split modules and harden scraping workflows
This commit is contained in:
@@ -3,7 +3,7 @@ import toast from 'react-hot-toast'
|
||||
import { Eye, KeyRound, Pencil, Save, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
|
||||
import { confirmAction } from './ConfirmDialog'
|
||||
import { confirmAction } from './confirmAction'
|
||||
|
||||
// Compact inline-editable provider table for use inside AdminPage's "外部API" tab.
|
||||
export function APIConfigsPanel() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
type ConfirmOptions = {
|
||||
export type ConfirmOptions = {
|
||||
title?: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
@@ -9,21 +8,7 @@ type ConfirmOptions = {
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
const close = (value: boolean) => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve(value)
|
||||
}
|
||||
root.render(<ConfirmDialog options={options} onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function ConfirmDialog({
|
||||
export function ConfirmDialog({
|
||||
options,
|
||||
onClose,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ImageOff, ImagePlus } from 'lucide-react'
|
||||
|
||||
type EpisodeArtworkToggleProps = {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EpisodeArtworkToggle({
|
||||
checked,
|
||||
onChange,
|
||||
title = '关闭后仍会写入每集文字元数据,只跳过每集图片',
|
||||
className = '',
|
||||
}: EpisodeArtworkToggleProps) {
|
||||
const Icon = checked ? ImagePlus : ImageOff
|
||||
const controlStyle = checked
|
||||
? { backgroundColor: 'var(--app-brand-soft)', borderColor: 'var(--app-brand-border)', color: 'var(--app-brand-text)' }
|
||||
: undefined
|
||||
const emphasisStyle = checked
|
||||
? { backgroundColor: 'var(--app-brand-emphasis)', color: 'var(--app-brand-text)' }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label="获取每集图片"
|
||||
title={title}
|
||||
onClick={() => onChange(!checked)}
|
||||
data-state={checked ? 'on' : 'off'}
|
||||
style={controlStyle}
|
||||
className={[
|
||||
'episode-artwork-toggle group inline-flex h-11 shrink-0 items-center gap-2 rounded-xl border px-3 text-sm font-semibold shadow-sm transition-all duration-200',
|
||||
checked ? 'episode-artwork-toggle--on' : '',
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500',
|
||||
'hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:scale-[0.98]',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className="episode-artwork-toggle__icon grid h-7 w-7 place-items-center rounded-lg transition-colors"
|
||||
style={emphasisStyle}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="whitespace-nowrap">每集图片</span>
|
||||
<span
|
||||
className="episode-artwork-toggle__state rounded-full px-2 py-0.5 text-[11px] font-bold"
|
||||
style={emphasisStyle}
|
||||
>
|
||||
{checked ? '开启' : '关闭'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,18 @@ export function GlobalEvents() {
|
||||
}
|
||||
}
|
||||
if (topic === 'scrape' && p.finished) {
|
||||
toast.success(`刮削完成:成功匹配 ${p.matched ?? 0} 项`)
|
||||
const processed = Number(p.processed ?? 0)
|
||||
const matched = Number(p.matched ?? 0)
|
||||
const failed = Number(p.failed ?? 0)
|
||||
if (failed > 0) {
|
||||
toast.error(`刮削完成但有错误:处理 ${processed} 项 · 成功匹配 ${matched} 项 · 失败 ${failed} 项`)
|
||||
return
|
||||
}
|
||||
if (processed > 0) {
|
||||
toast.success(`刮削完成:处理 ${processed} 项 · 成功匹配 ${matched} 项`)
|
||||
} else {
|
||||
toast.success(`刮削完成:没有待刮削媒体 · 成功匹配 ${matched} 项`)
|
||||
}
|
||||
}
|
||||
if (topic === 'subscription') {
|
||||
const queued = (p.queued as number | undefined) ?? 0
|
||||
|
||||
+89
-645
@@ -1,343 +1,75 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Activity, Clock, CloudDownload, Compass,
|
||||
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
|
||||
ListMusic, LogOut, MessageSquareText, Rss, Search, Trash2,
|
||||
Settings, Sliders, Sparkles, UserCog,
|
||||
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
|
||||
} from 'lucide-react'
|
||||
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { playProfilesAPI } from '../api/play_profiles'
|
||||
import { requestPIN } from './PinDialog'
|
||||
import type { Media, PlayProfile } from '../types'
|
||||
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
|
||||
import { LayoutSearchBox } from './LayoutSearchBox'
|
||||
import { LayoutSidebarContent } from './LayoutSidebarContent'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
import { useLayoutSearch } from './useLayoutSearch'
|
||||
import { useLayoutPermissions } from './useLayoutPermissions'
|
||||
import { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import { useLayoutSidebar } from './useLayoutSidebar'
|
||||
import { useThemeMode } from './useThemeMode'
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const permissions = usePermissionStore((s) => s.permissions)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const isPermissionLoading = usePermissionStore((s) => s.isLoading)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ media: true })
|
||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchItems, setSearchItems] = useState<Media[]>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchTotal, setSearchTotal] = useState(0)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const searchSeq = useRef(0)
|
||||
const searchCards = useMemo(() => groupSeries(searchItems).slice(0, 8), [searchItems])
|
||||
const theme = useThemeMode()
|
||||
const search = useLayoutSearch({
|
||||
pathname: location.pathname,
|
||||
locationSearch: location.search,
|
||||
navigate,
|
||||
})
|
||||
const { can, isAdmin } = useLayoutPermissions(user)
|
||||
const {
|
||||
isMobileDrawerOpen,
|
||||
isRouteIn,
|
||||
isSidebarOpen,
|
||||
openGroups,
|
||||
setIsMobileDrawerOpen,
|
||||
setIsSidebarOpen,
|
||||
toggleGroup,
|
||||
} = useLayoutSidebar(location.pathname)
|
||||
const {
|
||||
activeProfile,
|
||||
isProfileOpen,
|
||||
profiles,
|
||||
setIsProfileOpen,
|
||||
switchProfile,
|
||||
useDefaultProfile,
|
||||
} = useLayoutProfiles({ activeProfileId, setActiveProfile, user })
|
||||
|
||||
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setIsSidebarOpen(false)
|
||||
} else {
|
||||
setIsSidebarOpen(true)
|
||||
}
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobileDrawerOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !isPermissionLoading && Object.keys(permissions ?? {}).length === 0) {
|
||||
fetchPermissions().catch(() => undefined)
|
||||
}
|
||||
}, [fetchPermissions, isPermissionLoading, permissions, user])
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/search') {
|
||||
const query = new URLSearchParams(location.search).get('q') ?? ''
|
||||
setSearchQuery(query)
|
||||
}
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim()
|
||||
const seq = ++searchSeq.current
|
||||
if (!searchFocused || !query) {
|
||||
setSearchItems([])
|
||||
setSearchTotal(0)
|
||||
setSearchError('')
|
||||
setSearchLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSearchLoading(true)
|
||||
setSearchError('')
|
||||
const timer = window.setTimeout(() => {
|
||||
mediaAPI
|
||||
.search(query, 24)
|
||||
.then((data) => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setSearchItems(data.items ?? [])
|
||||
setSearchTotal(data.total ?? (data.items ?? []).length)
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setSearchItems([])
|
||||
setSearchTotal(0)
|
||||
setSearchError('搜索失败,请稍后再试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === searchSeq.current) setSearchLoading(false)
|
||||
})
|
||||
}, 220)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [searchFocused, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setProfiles([])
|
||||
setActiveProfile(null)
|
||||
return
|
||||
}
|
||||
playProfilesAPI
|
||||
.list()
|
||||
.then((rows) => {
|
||||
setProfiles(rows)
|
||||
const active = rows.find((p) => p.id === activeProfileId)
|
||||
if (!active) {
|
||||
const defaultProfile = rows.find((p) => p.is_default && !p.require_pin)
|
||||
setActiveProfile(defaultProfile?.id ?? null)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [activeProfileId, setActiveProfile, user])
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const can = (key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true
|
||||
const activeProfile = profiles.find((p) => p.id === activeProfileId) ?? null
|
||||
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
||||
const isRouteIn = (paths: string[]) =>
|
||||
paths.some((path) => (path === '/' ? location.pathname === '/' : location.pathname.startsWith(path)))
|
||||
const toggleGroup = (key: string) =>
|
||||
setOpenGroups((current) => ({ ...current, [key]: !current[key] }))
|
||||
|
||||
useEffect(() => {
|
||||
const groupPaths: Record<string, string[]> = {
|
||||
media: ['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'],
|
||||
personal: ['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'],
|
||||
downloads: ['/downloads', '/download-clients', '/subscriptions', '/site-search'],
|
||||
tools: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'],
|
||||
system: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'],
|
||||
}
|
||||
const active = Object.entries(groupPaths).find(([, paths]) => isRouteIn(paths))?.[0]
|
||||
if (active) {
|
||||
setOpenGroups((current) => (current[active] ? current : { ...current, [active]: true }))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
setSearchFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProfileSwitch = async (profile: PlayProfile) => {
|
||||
if (activeProfileId === profile.id) {
|
||||
setIsProfileOpen(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
let pinToken: string | null = null
|
||||
if (profile.require_pin) {
|
||||
const pin = await requestPIN({ profileName: profile.name })
|
||||
if (!pin) return
|
||||
const verified = await playProfilesAPI.verifyPin(profile.id, pin)
|
||||
pinToken = verified.token
|
||||
}
|
||||
setActiveProfile(profile.id, pinToken)
|
||||
setIsProfileOpen(false)
|
||||
toast.success(`已切换到「${profile.name}」`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'PIN 验证失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full flex-col bg-white border-r border-gray-200/80">
|
||||
{/* Brand Logo & Brand Title */}
|
||||
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/mediastationgo-logo.svg"
|
||||
alt="MediaStationGo"
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
{(isSidebarOpen || isMobileDrawerOpen) && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[#111827]"
|
||||
>
|
||||
MediaStationGo
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Toggle Collapse Button for Large Screen */}
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="rounded-xl p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors hidden lg:block"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
{/* Mobile Drawer Close Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(false)}
|
||||
className="rounded-xl p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors block lg:hidden"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation List */}
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
|
||||
<SidebarGroup
|
||||
id="media"
|
||||
icon={<Home size={18} />}
|
||||
label="影音中心"
|
||||
collapsed={!sidebarExpanded}
|
||||
open={openGroups.media ?? true}
|
||||
active={isRouteIn(['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'])}
|
||||
onToggle={toggleGroup}
|
||||
>
|
||||
<SidebarLink to="/" icon={<Home size={16} />} label="系统首页" end child />
|
||||
<SidebarLink to="/libraries" icon={<LibraryIcon size={16} />} label="媒体库" child />
|
||||
<SidebarLink to="/poster-wall" icon={<Image size={16} />} label="海报墙" child />
|
||||
{can('can_view_discover') && <SidebarLink to="/discover" icon={<Compass size={16} />} label="精彩发现" child />}
|
||||
{can('can_use_ai') && <SidebarLink to="/search" icon={<Search size={16} />} label="智能搜索" child />}
|
||||
{can('can_cast') && <SidebarLink to="/dlna" icon={<Cast size={16} />} label="DLNA 投屏" child />}
|
||||
{can('can_use_ai_assistant') && <SidebarLink to="/ai" icon={<Sparkles size={16} />} label="AI 助理" child />}
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup
|
||||
id="personal"
|
||||
icon={<UserIcon size={18} />}
|
||||
label="个人空间"
|
||||
collapsed={!sidebarExpanded}
|
||||
open={openGroups.personal ?? false}
|
||||
active={isRouteIn(['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'])}
|
||||
onToggle={toggleGroup}
|
||||
>
|
||||
<SidebarLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" child />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" child />
|
||||
<SidebarLink to="/history" icon={<Clock size={16} />} label="观看历史" child />
|
||||
<SidebarLink to="/profile" icon={<UserIcon size={16} />} label="账号信息" child />
|
||||
<SidebarLink to="/play-profiles" icon={<UserCog size={16} />} label="观影 Profile" child />
|
||||
</SidebarGroup>
|
||||
|
||||
{(can('can_manage_downloads') || can('can_manage_subscriptions') || can('can_manage_sites')) && (
|
||||
<SidebarGroup
|
||||
id="downloads"
|
||||
icon={<CloudDownload size={18} />}
|
||||
label="下载订阅"
|
||||
collapsed={!sidebarExpanded}
|
||||
open={openGroups.downloads ?? false}
|
||||
active={isRouteIn(['/downloads', '/download-clients', '/subscriptions', '/site-search'])}
|
||||
onToggle={toggleGroup}
|
||||
>
|
||||
{can('can_manage_downloads') && <SidebarLink to="/downloads" icon={<CloudDownload size={16} />} label="下载中心" child />}
|
||||
{can('can_manage_subscriptions') && <SidebarLink to="/subscriptions" icon={<Rss size={16} />} label="订阅管理" child />}
|
||||
{can('can_manage_sites') && <SidebarLink to="/site-search" icon={<Search size={16} />} label="站点检索" child />}
|
||||
{isAdmin && <SidebarLink to="/download-clients" icon={<Sliders size={16} />} label="下载器管理" child />}
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<SidebarGroup
|
||||
id="tools"
|
||||
icon={<HardDrive size={18} />}
|
||||
label="存储工具"
|
||||
collapsed={!sidebarExpanded}
|
||||
open={openGroups.tools ?? false}
|
||||
active={isRouteIn(['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'])}
|
||||
onToggle={toggleGroup}
|
||||
>
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={16} />} label="存储与文件" child />
|
||||
<SidebarLink to="/storage-config" icon={<CloudDownload size={16} />} label="外部存储" child />
|
||||
<SidebarLink to="/files" icon={<LibraryIcon size={16} />} label="文件管理" child />
|
||||
<SidebarLink to="/strm" icon={<Cast size={16} />} label="STRM 管理" child />
|
||||
<SidebarLink to="/duplicates" icon={<Image size={16} />} label="重复文件" child />
|
||||
<SidebarLink to="/tasks" icon={<Activity size={16} />} label="任务队列" child />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={16} />} label="计划任务" child />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={16} />} label="回收站" child />
|
||||
<SidebarLink to="/stats" icon={<Activity size={16} />} label="运行状态" child />
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup
|
||||
id="system"
|
||||
icon={<Settings size={18} />}
|
||||
label="系统管理"
|
||||
collapsed={!sidebarExpanded}
|
||||
open={openGroups.system ?? false}
|
||||
active={isRouteIn(['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'])}
|
||||
onToggle={toggleGroup}
|
||||
>
|
||||
<SidebarLink to="/admin" icon={<Settings size={16} />} label="媒体与用户" child />
|
||||
<SidebarLink to="/sites" icon={<Globe size={16} />} label="站点管理" child />
|
||||
<SidebarLink to="/notify-channels" icon={<MessageSquareText size={16} />} label="通知渠道" child />
|
||||
<SidebarLink to="/assistant" icon={<Sparkles size={16} />} label="AI 会话" child />
|
||||
<SidebarLink to="/license" icon={<KeySquare size={16} />} label="授权许可" child />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={16} />} label="系统设置" child />
|
||||
</SidebarGroup>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Logout Action */}
|
||||
<div className="border-t border-gray-100 p-4 bg-gray-50/50">
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login') }}
|
||||
className={clsx(
|
||||
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout",
|
||||
(isSidebarOpen || isMobileDrawerOpen) ? "justify-start text-gray-500 hover:bg-red-50 hover:text-red-600" : "justify-center text-gray-500 hover:text-red-600"
|
||||
)}
|
||||
title={`安全登出 (${user?.username})`}
|
||||
>
|
||||
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||
{(isSidebarOpen || isMobileDrawerOpen) && <span>安全退出</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<LayoutSidebarContent
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
isMobileDrawerOpen={isMobileDrawerOpen}
|
||||
openGroups={openGroups}
|
||||
isAdmin={isAdmin}
|
||||
username={user?.username}
|
||||
can={can}
|
||||
isRouteIn={isRouteIn}
|
||||
onToggleGroup={toggleGroup}
|
||||
onToggleSidebar={() => setIsSidebarOpen((current) => !current)}
|
||||
onCloseMobileDrawer={() => setIsMobileDrawerOpen(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#f9fafb] text-gray-900 font-body select-none">
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
|
||||
{/* 1. Desktop Persistent Sidebar */}
|
||||
<aside className={clsx(
|
||||
@@ -377,124 +109,37 @@ export function Layout() {
|
||||
<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 className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
|
||||
{/* Header Left Area: Hamburger and Search */}
|
||||
<div className="flex items-center gap-4 flex-1 max-w-lg">
|
||||
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
|
||||
{/* 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"
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors 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-500"
|
||||
)}>
|
||||
<Search size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onMouseDown={() => setSearchFocused(true)}
|
||||
onClick={() => setSearchFocused(true)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => window.setTimeout(() => setSearchFocused(false), 120)}
|
||||
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-500 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-xl border border-gray-200 bg-white px-1.5 py-0.5 text-[9px] font-bold text-gray-500 uppercase tracking-wider">
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{searchFocused && searchQuery.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ duration: 0.14 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl"
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-2">
|
||||
{searchLoading && (
|
||||
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500">
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
搜索中...
|
||||
</div>
|
||||
)}
|
||||
{!searchLoading && searchError && (
|
||||
<div className="px-3 py-4 text-sm text-red-500">{searchError}</div>
|
||||
)}
|
||||
{!searchLoading && !searchError && searchCards.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-gray-500">没有找到匹配的本地媒体</div>
|
||||
)}
|
||||
{!searchLoading && !searchError && searchCards.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{searchCards.map((card) => (
|
||||
<Link
|
||||
key={card.key}
|
||||
to={seriesCardLink(card)}
|
||||
onClick={() => setSearchFocused(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-gray-100">
|
||||
{card.rep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(card.rep.poster_url)}
|
||||
alt={card.rep.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-400">
|
||||
<LibraryIcon size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-gray-900">
|
||||
{card.rep.title || card.rep.original_name || '未命名媒体'}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-500">
|
||||
{card.rep.year ? <span>{card.rep.year}</span> : null}
|
||||
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
|
||||
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(searchQuery.trim())}`}
|
||||
onClick={() => setSearchFocused(false)}
|
||||
className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm font-semibold text-brand-600 hover:bg-brand-50/60"
|
||||
>
|
||||
<span>查看全部搜索结果</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{searchTotal > 0 ? `${searchTotal} 个条目` : 'Enter'}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
<LayoutSearchBox
|
||||
query={search.query}
|
||||
focused={search.focused}
|
||||
loading={search.loading}
|
||||
error={search.error}
|
||||
cards={search.cards}
|
||||
total={search.total}
|
||||
onQueryChange={search.setQuery}
|
||||
onFocusedChange={search.setFocused}
|
||||
onSubmit={search.submit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header Right Area: Actions, Notifications, Profile Dropdown */}
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
|
||||
{/* Quick search button shown ONLY on Mobile screens */}
|
||||
<Link
|
||||
to="/search"
|
||||
className="rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 sm:hidden"
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors sm:hidden"
|
||||
>
|
||||
<Search size={18} />
|
||||
</Link>
|
||||
@@ -503,7 +148,7 @@ export function Layout() {
|
||||
{can('can_view_discover') && (
|
||||
<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"
|
||||
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>发现新片</span>
|
||||
@@ -516,122 +161,33 @@ export function Layout() {
|
||||
to="/notify-channels"
|
||||
title="通知配置"
|
||||
aria-label="打开通知配置"
|
||||
className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all"
|
||||
className="relative rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<MessageSquareText size={18} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Horizontal divider lines */}
|
||||
<span className="h-6 w-px bg-gray-200" />
|
||||
<LayoutThemeToggle mode={theme.mode} onChange={theme.setMode} />
|
||||
|
||||
{/* 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">
|
||||
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-gray-500" />
|
||||
</button>
|
||||
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
|
||||
|
||||
<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>
|
||||
{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" />
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
||||
当前观影 Profile
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveProfile(null)
|
||||
setIsProfileOpen(false)
|
||||
}}
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
|
||||
!activeProfileId ? 'bg-gray-950 text-white' : 'text-gray-600 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<span>账号默认</span>
|
||||
<span>{!activeProfileId ? '使用中' : ''}</span>
|
||||
</button>
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => handleProfileSwitch(profile)}
|
||||
className={clsx(
|
||||
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
|
||||
activeProfileId === profile.id ? 'bg-gray-950 text-white' : 'text-gray-600 hover:bg-gray-50',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{profile.name}</span>
|
||||
<span className="ml-2 shrink-0">{profile.allow_adult ? '成人' : '安全'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isOpen={isProfileOpen}
|
||||
profiles={profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={activeProfile}
|
||||
onToggle={() => setIsProfileOpen((open) => !open)}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
onUseDefaultProfile={useDefaultProfile}
|
||||
onSwitchProfile={switchProfile}
|
||||
onLogout={() => {
|
||||
setIsProfileOpen(false)
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -653,120 +209,8 @@ export function Layout() {
|
||||
</main>
|
||||
|
||||
{/* Absolute Footer Frame */}
|
||||
<AppFooter className="border-t border-gray-200/50 bg-white py-5 text-center text-xs text-gray-500" />
|
||||
<AppFooter className="border-t border-[var(--app-border)] bg-[var(--app-panel)] py-5 text-center text-xs text-[var(--app-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarGroupProps {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
collapsed?: boolean;
|
||||
open?: boolean;
|
||||
active?: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function SidebarGroup({ id, icon, label, children, collapsed, open, active, onToggle }: SidebarGroupProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className={clsx(
|
||||
'group relative flex w-full items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-bold transition-all duration-300',
|
||||
active ? 'bg-gray-950 text-white shadow-sm' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-900',
|
||||
collapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<span className={clsx('flex h-5 w-5 shrink-0 items-center justify-center', active ? 'text-[#c9954a]' : 'text-gray-500 group-hover:text-gray-700')}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={clsx('transition-transform duration-200', open && 'rotate-180')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="absolute left-full ml-3 rounded-xl bg-gray-900 px-2.5 py-1.5 text-xs font-semibold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{!collapsed && open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-1 pb-1 pl-3">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarLinkProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
collapsed?: boolean;
|
||||
child?: boolean;
|
||||
}
|
||||
|
||||
function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 relative group",
|
||||
child && "py-2.5 text-[13px]",
|
||||
isActive
|
||||
? "bg-[#111827] text-white shadow-sm"
|
||||
: "text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={clsx(
|
||||
"flex shrink-0 items-center justify-center w-5 h-5 transition-transform duration-300 group-hover:scale-110",
|
||||
isActive ? "text-[#c9954a]" : "text-gray-500 group-hover:text-gray-700"
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="absolute left-full ml-3 px-2.5 py-1.5 rounded-xl 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Library as LibraryIcon, Search } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { seriesCardLink, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
type LayoutSearchBoxProps = {
|
||||
query: string
|
||||
focused: boolean
|
||||
loading: boolean
|
||||
error: string
|
||||
cards: SeriesCard[]
|
||||
total: number
|
||||
onQueryChange: (value: string) => void
|
||||
onFocusedChange: (focused: boolean) => void
|
||||
onSubmit: (event: FormEvent) => void
|
||||
}
|
||||
|
||||
export function LayoutSearchBox({
|
||||
query,
|
||||
focused,
|
||||
loading,
|
||||
error,
|
||||
cards,
|
||||
total,
|
||||
onQueryChange,
|
||||
onFocusedChange,
|
||||
onSubmit,
|
||||
}: LayoutSearchBoxProps) {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="relative hidden w-full sm:block">
|
||||
<span className={clsx(
|
||||
'absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200',
|
||||
focused ? 'text-brand-500' : 'text-[var(--app-muted)]',
|
||||
)}>
|
||||
<Search size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onMouseDown={() => onFocusedChange(true)}
|
||||
onClick={() => onFocusedChange(true)}
|
||||
onFocus={() => onFocusedChange(true)}
|
||||
onBlur={() => window.setTimeout(() => onFocusedChange(false), 120)}
|
||||
placeholder="搜索电影、电视剧、演员、种子站点..."
|
||||
className="w-full rounded-full border border-[var(--app-border)] bg-[var(--app-control-bg)] py-2.5 pl-11 pr-12 text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] outline-none transition-all duration-300 focus:border-brand-500 focus:bg-[var(--app-panel)] focus:ring-4 focus:ring-brand-100/40"
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{focused && trimmedQuery && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ duration: 0.14 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-2">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 px-3 py-4 text-sm text-[var(--app-muted)]">
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
搜索中...
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div className="px-3 py-4 text-sm text-red-500">{error}</div>
|
||||
)}
|
||||
{!loading && !error && cards.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-[var(--app-muted)]">没有找到匹配的本地媒体</div>
|
||||
)}
|
||||
{!loading && !error && cards.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{cards.map((card) => (
|
||||
<SearchResultItem
|
||||
key={card.key}
|
||||
card={card}
|
||||
onClick={() => onFocusedChange(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(trimmedQuery)}`}
|
||||
onClick={() => onFocusedChange(false)}
|
||||
className="flex items-center justify-between border-t border-[var(--app-border)] px-4 py-3 text-sm font-semibold text-brand-500 hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<span>查看全部搜索结果</span>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
{total > 0 ? `${total} 个条目` : 'Enter'}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResultItem({ card, onClick }: { card: SeriesCard; onClick: () => void }) {
|
||||
return (
|
||||
<Link
|
||||
to={seriesCardLink(card)}
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{card.rep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(card.rep.poster_url, card.rep.updated_at)}
|
||||
alt={card.rep.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<LibraryIcon size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--app-text)]">
|
||||
{card.rep.title || card.rep.original_name || '未命名媒体'}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--app-muted)]">
|
||||
{card.rep.year ? <span>{card.rep.year}</span> : null}
|
||||
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
|
||||
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { LogOut, Menu, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { LAYOUT_NAV_GROUPS, NAV_GROUP_PATHS, type LayoutNavItem } from './layoutNavigation'
|
||||
import { SidebarGroup, SidebarLink } from './LayoutSidebarNav'
|
||||
|
||||
type LayoutSidebarContentProps = {
|
||||
isSidebarOpen: boolean
|
||||
isMobileDrawerOpen: boolean
|
||||
openGroups: Record<string, boolean>
|
||||
isAdmin: boolean
|
||||
username?: string
|
||||
can: (key: string) => boolean
|
||||
isRouteIn: (paths: string[]) => boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
onToggleSidebar: () => void
|
||||
onCloseMobileDrawer: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function LayoutSidebarContent({
|
||||
isSidebarOpen,
|
||||
isMobileDrawerOpen,
|
||||
openGroups,
|
||||
isAdmin,
|
||||
username,
|
||||
can,
|
||||
isRouteIn,
|
||||
onToggleGroup,
|
||||
onToggleSidebar,
|
||||
onCloseMobileDrawer,
|
||||
onLogout,
|
||||
}: LayoutSidebarContentProps) {
|
||||
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
||||
const isItemVisible = (item: LayoutNavItem) =>
|
||||
(!item.adminOnly || isAdmin) && (!item.permission || can(item.permission))
|
||||
const visibleGroups = LAYOUT_NAV_GROUPS
|
||||
.filter((group) => !group.adminOnly || isAdmin)
|
||||
.map((group) => ({
|
||||
group,
|
||||
items: group.items.filter(isItemVisible),
|
||||
}))
|
||||
.filter(({ items }) => items.length > 0)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/mediastationgo-logo.svg"
|
||||
alt="MediaStationGo"
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
{sidebarExpanded && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
|
||||
>
|
||||
MediaStationGo
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors hidden lg:block"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onCloseMobileDrawer}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors block lg:hidden"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
|
||||
{visibleGroups.map(({ group, items }) => {
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<SidebarGroup
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
icon={<GroupIcon size={18} />}
|
||||
label={group.label}
|
||||
collapsed={!sidebarExpanded}
|
||||
open={openGroups[group.id] ?? group.id === 'media'}
|
||||
active={isRouteIn(NAV_GROUP_PATHS[group.id])}
|
||||
onToggle={onToggleGroup}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
<SidebarLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
icon={<ItemIcon size={16} />}
|
||||
label={item.label}
|
||||
end={item.end}
|
||||
child
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SidebarGroup>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className={clsx(
|
||||
'flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout',
|
||||
sidebarExpanded
|
||||
? 'justify-start text-[var(--app-muted)] hover:bg-[var(--app-danger-soft)] hover:text-red-500'
|
||||
: 'justify-center text-[var(--app-muted)] hover:text-red-500',
|
||||
)}
|
||||
title={`安全登出 (${username ?? ''})`}
|
||||
>
|
||||
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||
{sidebarExpanded && <span>安全退出</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type SidebarGroupProps = {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
children: ReactNode
|
||||
collapsed?: boolean
|
||||
open?: boolean
|
||||
active?: boolean
|
||||
onToggle: (id: string) => void
|
||||
}
|
||||
|
||||
export function SidebarGroup({ id, icon, label, children, collapsed, open, active, onToggle }: SidebarGroupProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(id)}
|
||||
className={clsx(
|
||||
'group relative flex w-full items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-bold transition-all duration-300',
|
||||
active
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
collapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center',
|
||||
active ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={clsx('transition-transform duration-200', open && 'rotate-180')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="absolute left-full z-50 ml-3 rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{!collapsed && open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-1 pb-1 pl-3">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarLinkProps = {
|
||||
to: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
end?: boolean
|
||||
collapsed?: boolean
|
||||
child?: boolean
|
||||
}
|
||||
|
||||
export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
||||
child && 'py-2.5 text-[13px]',
|
||||
isActive
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={clsx(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
||||
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Monitor, Moon, Sun } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { ThemeMode } from './useThemeMode'
|
||||
|
||||
type LayoutThemeToggleProps = {
|
||||
mode: ThemeMode
|
||||
onChange: (mode: ThemeMode) => void
|
||||
}
|
||||
|
||||
const options: Array<{
|
||||
mode: ThemeMode
|
||||
label: string
|
||||
icon: typeof Sun
|
||||
}> = [
|
||||
{ mode: 'light', label: '白天模式', icon: Sun },
|
||||
{ mode: 'dark', label: '夜晚模式', icon: Moon },
|
||||
{ mode: 'system', label: '跟随系统', icon: Monitor },
|
||||
]
|
||||
|
||||
export function LayoutThemeToggle({ mode, onChange }: LayoutThemeToggleProps) {
|
||||
return (
|
||||
<div className="flex items-center rounded-full border border-[var(--app-border)] bg-[var(--app-control-bg)] p-1 shadow-sm">
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon
|
||||
const active = mode === option.mode
|
||||
return (
|
||||
<button
|
||||
key={option.mode}
|
||||
type="button"
|
||||
title={option.label}
|
||||
aria-label={option.label}
|
||||
aria-pressed={active}
|
||||
className={clsx(
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-full transition-all duration-200',
|
||||
active
|
||||
? 'bg-[var(--app-command-bg)] text-[var(--app-command-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
)}
|
||||
onClick={() => onChange(option.mode)}
|
||||
>
|
||||
<Icon size={15} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown, LogOut, Settings, User as UserIcon, UserCog } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { PlayProfile } from '../types'
|
||||
|
||||
type LayoutUser = {
|
||||
username?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
type LayoutUserMenuProps = {
|
||||
user: LayoutUser | null | undefined
|
||||
isOpen: boolean
|
||||
profiles: PlayProfile[]
|
||||
activeProfileId: string | null
|
||||
activeProfile: PlayProfile | null
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
onUseDefaultProfile: () => void
|
||||
onSwitchProfile: (profile: PlayProfile) => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function LayoutUserMenu({
|
||||
user,
|
||||
isOpen,
|
||||
profiles,
|
||||
activeProfileId,
|
||||
activeProfile,
|
||||
onToggle,
|
||||
onClose,
|
||||
onUseDefaultProfile,
|
||||
onSwitchProfile,
|
||||
onLogout,
|
||||
}: LayoutUserMenuProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2.5 rounded-full border border-[var(--app-border)] p-1 pr-3 transition-all hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] font-display text-xs font-bold text-white shadow-sm">
|
||||
{user?.username?.slice(0, 2).toUpperCase() || 'US'}
|
||||
</div>
|
||||
<div className="hidden text-left md:block">
|
||||
<p className="text-xs font-bold leading-none text-[var(--app-text)]">{user?.username}</p>
|
||||
<p className="mt-0.5 text-[9px] font-bold uppercase leading-none tracking-wider text-[var(--app-muted)]">
|
||||
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-[var(--app-muted)]" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={onClose} />
|
||||
<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 z-20 mt-3 w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
|
||||
>
|
||||
<UserMenuLink to="/profile" icon={<UserIcon size={16} />} label="个人基本信息" onClick={onClose} />
|
||||
{user?.role === 'admin' && (
|
||||
<UserMenuLink to="/admin" icon={<Settings size={16} />} label="管理主控制台" onClick={onClose} />
|
||||
)}
|
||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
当前观影 Profile
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={onUseDefaultProfile}
|
||||
className={profileButtonClass(!activeProfileId)}
|
||||
>
|
||||
<span>账号默认</span>
|
||||
<span>{!activeProfileId ? '使用中' : ''}</span>
|
||||
</button>
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => onSwitchProfile(profile)}
|
||||
className={profileButtonClass(activeProfileId === profile.id)}
|
||||
>
|
||||
<span className="truncate">{profile.name}</span>
|
||||
<span className="ml-2 shrink-0">{profile.allow_adult ? '成人' : '安全'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<UserMenuLink to="/play-profiles" icon={<UserCog size={16} />} label="管理观影 Profile" onClick={onClose} />
|
||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-red-500 transition-colors hover:bg-[var(--app-danger-soft)]"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>安全登出系统</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMenuLink({
|
||||
to,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function profileButtonClass(active: boolean): string {
|
||||
return clsx(
|
||||
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
|
||||
active
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)]'
|
||||
: 'text-[var(--app-subtle)] hover:bg-[var(--app-hover)]',
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ type ShortcutItem = {
|
||||
title: string
|
||||
description: string
|
||||
badge?: string
|
||||
group?: string
|
||||
}
|
||||
|
||||
type ManagementShortcutsProps = {
|
||||
@@ -15,43 +16,80 @@ type ManagementShortcutsProps = {
|
||||
}
|
||||
|
||||
export function ManagementShortcuts({ title, description, items }: ManagementShortcutsProps) {
|
||||
const groups = groupShortcutItems(items)
|
||||
const hasNamedGroups = groups.some((group) => group.label)
|
||||
|
||||
return (
|
||||
<section className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-[var(--app-border)] pb-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-bold text-ink-600">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-ink-50">{description}</p>}
|
||||
<h2 className="font-display text-lg font-bold text-[var(--app-text)]">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-[var(--app-muted)]">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="group rounded-2xl border border-gray-200 bg-gray-50/70 p-4 transition hover:-translate-y-0.5 hover:border-primary-400/50 hover:bg-white hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-bold text-ink-600">{item.title}</h3>
|
||||
{item.badge && (
|
||||
<span className="shrink-0 rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-bold text-brand-500">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="mt-0.5 shrink-0 text-sand-500 transition group-hover:translate-x-0.5 group-hover:text-brand-500"
|
||||
/>
|
||||
{hasNamedGroups ? (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label || 'default'} className="space-y-3">
|
||||
{group.label && <h3 className="text-xs font-bold uppercase tracking-wider text-[var(--app-brand-text)]">{group.label}</h3>}
|
||||
<ShortcutGrid items={group.items} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ShortcutGrid items={items} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function groupShortcutItems(items: ShortcutItem[]) {
|
||||
const groups: { label: string; items: ShortcutItem[] }[] = []
|
||||
items.forEach((item) => {
|
||||
const label = item.group ?? ''
|
||||
const group = groups.find((candidate) => candidate.label === label)
|
||||
if (group) {
|
||||
group.items.push(item)
|
||||
return
|
||||
}
|
||||
groups.push({ label, items: [item] })
|
||||
})
|
||||
return groups
|
||||
}
|
||||
|
||||
function ShortcutGrid({ items }: { items: ShortcutItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<ShortcutCard key={item.to} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShortcutCard({ item }: { item: ShortcutItem }) {
|
||||
return (
|
||||
<Link
|
||||
to={item.to}
|
||||
className="group rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] p-4 shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500/50 hover:bg-[var(--app-panel-soft)] hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-sm font-bold text-[var(--app-text)]">{item.title}</h3>
|
||||
{item.badge && (
|
||||
<span className="shrink-0 rounded-full border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-2 py-0.5 text-[10px] font-bold text-[var(--app-brand-text)]">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-[var(--app-muted)]">{item.description}</p>
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="mt-0.5 shrink-0 text-[var(--app-brand-text)] transition group-hover:translate-x-0.5 group-hover:text-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { EpisodeArtworkToggle } from './EpisodeArtworkToggle'
|
||||
|
||||
interface ManualScrapeDialogProps {
|
||||
open: boolean
|
||||
@@ -13,12 +14,12 @@ interface ManualScrapeDialogProps {
|
||||
defaultQuery?: string
|
||||
mediaType?: string
|
||||
scopeLabel?: string
|
||||
episodeArtwork?: boolean
|
||||
onClose: () => void
|
||||
onApplied?: () => void
|
||||
}
|
||||
|
||||
const providers = [
|
||||
{ value: 'all', label: '全部源' },
|
||||
{ value: 'tmdb', label: 'TMDb' },
|
||||
{ value: 'douban', label: '豆瓣' },
|
||||
{ value: 'bangumi', label: 'Bangumi' },
|
||||
@@ -33,11 +34,13 @@ export function ManualScrapeDialog({
|
||||
defaultQuery,
|
||||
mediaType,
|
||||
scopeLabel,
|
||||
episodeArtwork,
|
||||
onClose,
|
||||
onApplied,
|
||||
}: ManualScrapeDialogProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [provider, setProvider] = useState('all')
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
|
||||
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [applyingKey, setApplyingKey] = useState('')
|
||||
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
|
||||
@@ -50,10 +53,11 @@ export function ManualScrapeDialog({
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setQuery(defaultQuery || media?.title || '')
|
||||
setProvider('all')
|
||||
setSelectedProviders([])
|
||||
setIncludeEpisodeArtwork(episodeArtwork ?? false)
|
||||
setItems([])
|
||||
setApplyingKey('')
|
||||
}, [defaultQuery, media?.title, open])
|
||||
}, [defaultQuery, episodeArtwork, media?.title, open])
|
||||
|
||||
if (!open || !media) return null
|
||||
|
||||
@@ -67,7 +71,7 @@ export function ManualScrapeDialog({
|
||||
try {
|
||||
const results = await mediaAPI.manualScrapeSearch(media.id, {
|
||||
query: text,
|
||||
provider,
|
||||
provider: selectedProviders.length > 0 ? selectedProviders.join(',') : 'all',
|
||||
media_type: mediaType,
|
||||
})
|
||||
setItems(results)
|
||||
@@ -84,11 +88,12 @@ export function ManualScrapeDialog({
|
||||
const key = candidateKey(item)
|
||||
setApplyingKey(key)
|
||||
try {
|
||||
const options = { episode_images: includeEpisodeArtwork }
|
||||
if (targetIds.length > 1) {
|
||||
const result = await mediaAPI.applyManualScrapeBatch(targetIds, item)
|
||||
const result = await mediaAPI.applyManualScrapeBatch(targetIds, item, options)
|
||||
toast.success(`已应用到 ${result.applied} 个媒体`)
|
||||
} else {
|
||||
await mediaAPI.applyManualScrape(media.id, item)
|
||||
await mediaAPI.applyManualScrape(media.id, item, options)
|
||||
toast.success('已应用手动匹配')
|
||||
}
|
||||
onApplied?.()
|
||||
@@ -117,15 +122,30 @@ export function ManualScrapeDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 sm:flex-row">
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(event) => setProvider(event.target.value)}
|
||||
className="h-11 rounded-xl border border-sand-200 bg-white px-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
|
||||
>
|
||||
{providers.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedProviders([])}
|
||||
className={providerButtonClass(selectedProviders.length === 0)}
|
||||
>
|
||||
{selectedProviders.length === 0 && <Check size={13} />}
|
||||
全部源
|
||||
</button>
|
||||
{providers.map((item) => {
|
||||
const active = selectedProviders.includes(item.value)
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => toggleProvider(item.value, setSelectedProviders)}
|
||||
className={providerButtonClass(active)}
|
||||
>
|
||||
{active && <Check size={13} />}
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
|
||||
<input
|
||||
@@ -140,6 +160,13 @@ export function ManualScrapeDialog({
|
||||
{searching ? <LoaderCircle size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
搜索
|
||||
</button>
|
||||
{isEpisodeArtworkTarget(media, mediaType, targetIds.length) && (
|
||||
<EpisodeArtworkToggle
|
||||
checked={includeEpisodeArtwork}
|
||||
onChange={setIncludeEpisodeArtwork}
|
||||
title="关闭后仍写入每集简介、评分和时长,只跳过每集图片"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
@@ -190,6 +217,29 @@ function candidateKey(item: ManualScrapeCandidate): string {
|
||||
return `${item.source}:${item.tmdb_id || item.bangumi_id || item.douban_id || item.thetvdb_id || item.title}:${item.media_type || ''}`
|
||||
}
|
||||
|
||||
function toggleProvider(value: string, setSelectedProviders: Dispatch<SetStateAction<string[]>>) {
|
||||
setSelectedProviders((current) => {
|
||||
if (current.includes(value)) {
|
||||
return current.filter((item) => item !== value)
|
||||
}
|
||||
return [...current, value]
|
||||
})
|
||||
}
|
||||
|
||||
function providerButtonClass(active: boolean): string {
|
||||
return (
|
||||
'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' +
|
||||
(active
|
||||
? 'border-brand-300 bg-brand-50 text-brand-700'
|
||||
: 'border-sand-200 bg-white text-sand-600 hover:border-brand-200 hover:text-brand-600')
|
||||
)
|
||||
}
|
||||
|
||||
function isEpisodeArtworkTarget(media: Media, mediaType?: string, targetCount = 1): boolean {
|
||||
const type = (mediaType || '').toLowerCase()
|
||||
return type === 'tv' || type === 'anime' || type === 'variety' || media.season_num > 0 || media.episode_num > 0 || targetCount > 1
|
||||
}
|
||||
|
||||
function candidateIDText(item: ManualScrapeCandidate): string {
|
||||
const parts = [
|
||||
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
|
||||
|
||||
@@ -19,25 +19,26 @@ export const MediaCard = ({
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
||||
const posterSrc = imageURL(media.poster_url, media.updated_at)
|
||||
|
||||
useEffect(() => {
|
||||
setPosterFit('cover')
|
||||
}, [media.poster_url])
|
||||
}, [media.poster_url, media.updated_at])
|
||||
|
||||
const card = (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
whileHover={{ scale: 1.04, y: -6 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
|
||||
className="relative overflow-hidden rounded-2xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)]"
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] 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_var(--app-shadow)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gray-50">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<>
|
||||
{posterFit === 'contain' && (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
src={posterSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
@@ -46,7 +47,7 @@ export const MediaCard = ({
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
src={posterSrc}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -62,7 +63,7 @@ export const MediaCard = ({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-[var(--app-panel-soft)] text-[var(--app-muted)]">
|
||||
<Film size={28} className="stroke-[1.5]" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
|
||||
</div>
|
||||
@@ -70,7 +71,7 @@ export const MediaCard = ({
|
||||
|
||||
{/* Episode count badge */}
|
||||
{count !== undefined && count > 1 && (
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm border border-gray-200">
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
|
||||
<Layers size={10} className="text-[#c9954a]" />
|
||||
<span>{count} 集</span>
|
||||
</span>
|
||||
@@ -78,7 +79,7 @@ export const MediaCard = ({
|
||||
|
||||
{/* Rating Badge */}
|
||||
{(rating || (media as any).rating) && (
|
||||
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm border border-gray-200">
|
||||
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm">
|
||||
<Star size={10} fill="currentColor" />
|
||||
<span>{(rating || (media as any).rating).toFixed(1)}</span>
|
||||
</span>
|
||||
@@ -96,7 +97,7 @@ export const MediaCard = ({
|
||||
<Play size={10} fill="currentColor" className="text-white" />
|
||||
<span>立即观影</span>
|
||||
</span>
|
||||
<p className="text-[10px] text-gray-500 font-semibold line-clamp-2 leading-relaxed">
|
||||
<p className="text-[10px] text-gray-200 font-semibold line-clamp-2 leading-relaxed">
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
</motion.div>
|
||||
@@ -104,7 +105,7 @@ export const MediaCard = ({
|
||||
|
||||
{/* Progress Bar overlay */}
|
||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-gray-200">
|
||||
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-[var(--app-hover)]">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
@@ -114,14 +115,14 @@ export const MediaCard = ({
|
||||
</div>
|
||||
|
||||
{/* Media Metadata Info */}
|
||||
<div className="p-4 space-y-1 bg-white border-t border-gray-50">
|
||||
<p className="truncate text-sm font-bold text-gray-900 group-hover:text-brand-500 transition-colors duration-200">
|
||||
<div className="space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] p-4">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500">
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-500 font-bold uppercase tracking-wider">
|
||||
<div className="flex items-center justify-between text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
{media.video_codec && (
|
||||
<span className="px-1.5 py-0.5 rounded-xl bg-gray-50 text-gray-600 border border-gray-100">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
{media.video_codec}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { KeyRound } from 'lucide-react'
|
||||
|
||||
type PasswordOptions = {
|
||||
export type PasswordOptions = {
|
||||
title?: string
|
||||
message?: string
|
||||
confirmText?: string
|
||||
}
|
||||
|
||||
export function requestPassword(options: PasswordOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
const close = (value: string | null) => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve(value)
|
||||
}
|
||||
root.render(<PasswordDialog options={options} onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function PasswordDialog({
|
||||
export function PasswordDialog({
|
||||
options,
|
||||
onClose,
|
||||
}: {
|
||||
|
||||
@@ -50,18 +50,3 @@ export function PermissionGuard({
|
||||
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 权限检查工具函数
|
||||
export function checkPermission(
|
||||
permission: string,
|
||||
isSuper: boolean,
|
||||
tier: string,
|
||||
role: string,
|
||||
permissions: Record<string, boolean>
|
||||
): boolean {
|
||||
// 超级用户有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return true
|
||||
}
|
||||
return permissions[permission] === true
|
||||
}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { LockKeyhole } from 'lucide-react'
|
||||
|
||||
type PinOptions = {
|
||||
export type PinOptions = {
|
||||
title?: string
|
||||
message?: string
|
||||
profileName: string
|
||||
}
|
||||
|
||||
export function requestPIN(options: PinOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
const close = (value: string | null) => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve(value)
|
||||
}
|
||||
root.render(<PinDialog options={options} onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function PinDialog({
|
||||
export function PinDialog({
|
||||
options,
|
||||
onClose,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { ConfirmDialog, type ConfirmOptions } from './ConfirmDialog'
|
||||
|
||||
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
const close = (value: boolean) => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve(value)
|
||||
}
|
||||
root.render(<ConfirmDialog options={options} onClose={close} />)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Activity,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
Compass,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
Image,
|
||||
KeySquare,
|
||||
Library,
|
||||
ListMusic,
|
||||
MessageSquareText,
|
||||
Rss,
|
||||
Search,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
User,
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
|
||||
export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system'
|
||||
|
||||
export type LayoutNavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
end?: boolean
|
||||
permission?: string
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
export type LayoutNavGroup = {
|
||||
id: LayoutNavGroupID
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
activePaths: string[]
|
||||
adminOnly?: boolean
|
||||
items: LayoutNavItem[]
|
||||
}
|
||||
|
||||
export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
|
||||
{
|
||||
id: 'media',
|
||||
label: '媒体浏览',
|
||||
icon: Home,
|
||||
activePaths: ['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'],
|
||||
items: [
|
||||
{ to: '/', label: '系统首页', icon: Home, end: true },
|
||||
{ to: '/libraries', label: '媒体库', icon: Library },
|
||||
{ to: '/poster-wall', label: '海报墙', icon: Image },
|
||||
{ to: '/discover', label: '精彩发现', icon: Compass, permission: 'can_view_discover' },
|
||||
{ to: '/search', label: '智能搜索', icon: Search, permission: 'can_use_ai' },
|
||||
{ to: '/dlna', label: 'DLNA 投屏', icon: Cast, permission: 'can_cast' },
|
||||
{ to: '/ai', label: 'AI 助理', icon: Sparkles, permission: 'can_use_ai_assistant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'personal',
|
||||
label: '个人观影',
|
||||
icon: User,
|
||||
activePaths: ['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'],
|
||||
items: [
|
||||
{ to: '/favourites', label: '我的收藏', icon: Heart },
|
||||
{ to: '/playlists', label: '播放列表', icon: ListMusic },
|
||||
{ to: '/history', label: '观看历史', icon: Clock },
|
||||
{ to: '/profile', label: '账号信息', icon: User },
|
||||
{ to: '/play-profiles', label: '观影 Profile', icon: UserCog },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'downloads',
|
||||
label: '下载与订阅',
|
||||
icon: CloudDownload,
|
||||
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search'],
|
||||
items: [
|
||||
{ to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
|
||||
{ to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
|
||||
{ to: '/site-search', label: '站点检索', icon: Search, permission: 'can_manage_sites' },
|
||||
{ to: '/download-clients', label: '下载器管理', icon: Sliders, adminOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: '文件与自动化',
|
||||
icon: HardDrive,
|
||||
activePaths: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'],
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/storage', label: '存储与文件', icon: HardDrive },
|
||||
{ to: '/storage-config', label: '外部存储', icon: CloudDownload },
|
||||
{ to: '/files', label: '文件管理', icon: Library },
|
||||
{ to: '/strm', label: 'STRM 管理', icon: Cast },
|
||||
{ to: '/duplicates', label: '重复文件', icon: Image },
|
||||
{ to: '/tasks', label: '任务队列', icon: Activity },
|
||||
{ to: '/scheduler', label: '计划任务', icon: Clock },
|
||||
{ to: '/recycle', label: '回收站', icon: Trash2 },
|
||||
{ to: '/stats', label: '运行状态', icon: Activity },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '系统配置',
|
||||
icon: Settings,
|
||||
activePaths: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'],
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/admin', label: '媒体与用户', icon: Settings },
|
||||
{ to: '/sites', label: '站点管理', icon: Globe },
|
||||
{ to: '/notify-channels', label: '通知渠道', icon: MessageSquareText },
|
||||
{ to: '/assistant', label: 'AI 会话', icon: Sparkles },
|
||||
{ to: '/license', label: '授权许可', icon: KeySquare },
|
||||
{ to: '/settings', label: '系统设置', icon: Sliders },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const NAV_GROUP_PATHS: Record<LayoutNavGroupID, string[]> = LAYOUT_NAV_GROUPS.reduce(
|
||||
(paths, group) => ({ ...paths, [group.id]: group.activePaths }),
|
||||
{} as Record<LayoutNavGroupID, string[]>,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { PinDialog, type PinOptions } from './PinDialog'
|
||||
|
||||
export function requestPIN(options: PinOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
const close = (value: string | null) => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve(value)
|
||||
}
|
||||
root.render(<PinDialog options={options} onClose={close} />)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { PasswordDialog, type PasswordOptions } from './PasswordDialog'
|
||||
|
||||
export function requestPassword(options: PasswordOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
const close = (value: string | null) => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve(value)
|
||||
}
|
||||
root.render(<PasswordDialog options={options} onClose={close} />)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import type { User } from '../types'
|
||||
|
||||
export function useLayoutPermissions(user: User | null | undefined) {
|
||||
const permissions = usePermissionStore((state) => state.permissions)
|
||||
const isSuper = usePermissionStore((state) => state.isSuper)
|
||||
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
|
||||
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !isPermissionLoading && Object.keys(permissions ?? {}).length === 0) {
|
||||
fetchPermissions().catch(() => undefined)
|
||||
}
|
||||
}, [fetchPermissions, isPermissionLoading, permissions, user])
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const can = useCallback(
|
||||
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
|
||||
[isAdmin, isSuper, permissions],
|
||||
)
|
||||
|
||||
return { can, isAdmin }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playProfilesAPI } from '../api/play_profiles'
|
||||
import type { PlayProfile, User } from '../types'
|
||||
import { requestPIN } from './requestPIN'
|
||||
|
||||
type UseLayoutProfilesOptions = {
|
||||
activeProfileId: string | null
|
||||
setActiveProfile: (id: string | null, pinToken?: string | null) => void
|
||||
user: User | null | undefined
|
||||
}
|
||||
|
||||
export function useLayoutProfiles({
|
||||
activeProfileId,
|
||||
setActiveProfile,
|
||||
user,
|
||||
}: UseLayoutProfilesOptions) {
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setProfiles([])
|
||||
setActiveProfile(null)
|
||||
return
|
||||
}
|
||||
playProfilesAPI
|
||||
.list()
|
||||
.then((rows) => {
|
||||
setProfiles(rows)
|
||||
const active = rows.find((profile) => profile.id === activeProfileId)
|
||||
if (!active) {
|
||||
const defaultProfile = rows.find((profile) => profile.is_default && !profile.require_pin)
|
||||
setActiveProfile(defaultProfile?.id ?? null)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [activeProfileId, setActiveProfile, user])
|
||||
|
||||
const activeProfile = useMemo(
|
||||
() => profiles.find((profile) => profile.id === activeProfileId) ?? null,
|
||||
[activeProfileId, profiles],
|
||||
)
|
||||
|
||||
const switchProfile = useCallback(
|
||||
async (profile: PlayProfile) => {
|
||||
if (activeProfileId === profile.id) {
|
||||
setIsProfileOpen(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
let pinToken: string | null = null
|
||||
if (profile.require_pin) {
|
||||
const pin = await requestPIN({ profileName: profile.name })
|
||||
if (!pin) return
|
||||
const verified = await playProfilesAPI.verifyPin(profile.id, pin)
|
||||
pinToken = verified.token
|
||||
}
|
||||
setActiveProfile(profile.id, pinToken)
|
||||
setIsProfileOpen(false)
|
||||
toast.success(`已切换到「${profile.name}」`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'PIN 验证失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
},
|
||||
[activeProfileId, setActiveProfile],
|
||||
)
|
||||
|
||||
const useDefaultProfile = useCallback(() => {
|
||||
setActiveProfile(null)
|
||||
setIsProfileOpen(false)
|
||||
}, [setActiveProfile])
|
||||
|
||||
return {
|
||||
activeProfile,
|
||||
isProfileOpen,
|
||||
profiles,
|
||||
setIsProfileOpen,
|
||||
switchProfile,
|
||||
useDefaultProfile,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { groupSeries } from '../utils/groupSeries'
|
||||
|
||||
type UseLayoutSearchOptions = {
|
||||
pathname: string
|
||||
locationSearch: string
|
||||
navigate: NavigateFunction
|
||||
}
|
||||
|
||||
export function useLayoutSearch({ pathname, locationSearch, navigate }: UseLayoutSearchOptions) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const searchSeq = useRef(0)
|
||||
const cards = useMemo(() => groupSeries(items).slice(0, 8), [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === '/search') {
|
||||
setQuery(new URLSearchParams(locationSearch).get('q') ?? '')
|
||||
}
|
||||
}, [pathname, locationSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedQuery = query.trim()
|
||||
const seq = ++searchSeq.current
|
||||
if (!focused || !trimmedQuery) {
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const timer = window.setTimeout(() => {
|
||||
mediaAPI
|
||||
.search(trimmedQuery, 24)
|
||||
.then((data) => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setItems(data.items ?? [])
|
||||
setTotal(data.total ?? (data.items ?? []).length)
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError('搜索失败,请稍后再试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === searchSeq.current) setLoading(false)
|
||||
})
|
||||
}, 220)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [focused, query])
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const trimmedQuery = query.trim()
|
||||
if (trimmedQuery) {
|
||||
navigate(`/search?q=${encodeURIComponent(trimmedQuery)}`)
|
||||
setFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cards,
|
||||
error,
|
||||
focused,
|
||||
loading,
|
||||
query,
|
||||
total,
|
||||
setFocused,
|
||||
setQuery,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { NAV_GROUP_PATHS } from './layoutNavigation'
|
||||
|
||||
export function useLayoutSidebar(pathname: string) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ media: true })
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsSidebarOpen(window.innerWidth >= 1024)
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobileDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
const isRouteIn = useCallback(
|
||||
(paths: string[]) =>
|
||||
paths.some((path) => (path === '/' ? pathname === '/' : pathname.startsWith(path))),
|
||||
[pathname],
|
||||
)
|
||||
|
||||
const toggleGroup = useCallback(
|
||||
(key: string) => setOpenGroups((current) => ({ ...current, [key]: !current[key] })),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const active = Object.entries(NAV_GROUP_PATHS).find(([, paths]) => isRouteIn(paths))?.[0]
|
||||
if (active) {
|
||||
setOpenGroups((current) => (current[active] ? current : { ...current, [active]: true }))
|
||||
}
|
||||
}, [isRouteIn])
|
||||
|
||||
return {
|
||||
isMobileDrawerOpen,
|
||||
isRouteIn,
|
||||
isSidebarOpen,
|
||||
openGroups,
|
||||
setIsMobileDrawerOpen,
|
||||
setIsSidebarOpen,
|
||||
toggleGroup,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
export type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
const THEME_STORAGE_KEY = 'mediastationgo.theme'
|
||||
const DARK_QUERY = '(prefers-color-scheme: dark)'
|
||||
|
||||
function readStoredTheme(): ThemeMode {
|
||||
if (typeof window === 'undefined') return 'system'
|
||||
const value = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||
return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
|
||||
}
|
||||
|
||||
function systemTheme(): ResolvedTheme {
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
return window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
||||
return mode === 'system' ? systemTheme() : mode
|
||||
}
|
||||
|
||||
function applyTheme(mode: ThemeMode) {
|
||||
if (typeof document === 'undefined') return
|
||||
const resolved = resolveTheme(mode)
|
||||
const root = document.documentElement
|
||||
root.dataset.themeMode = mode
|
||||
root.dataset.theme = resolved
|
||||
root.style.colorScheme = resolved
|
||||
}
|
||||
|
||||
export function initializeThemeMode() {
|
||||
applyTheme(readStoredTheme())
|
||||
}
|
||||
|
||||
export function useThemeMode() {
|
||||
const [mode, setModeState] = useState<ThemeMode>(() => readStoredTheme())
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => resolveTheme(readStoredTheme()))
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(mode)
|
||||
setResolvedTheme(resolveTheme(mode))
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, mode)
|
||||
}, [mode])
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(DARK_QUERY)
|
||||
const update = () => {
|
||||
if (mode === 'system') {
|
||||
applyTheme(mode)
|
||||
setResolvedTheme(resolveTheme(mode))
|
||||
}
|
||||
}
|
||||
media.addEventListener('change', update)
|
||||
return () => media.removeEventListener('change', update)
|
||||
}, [mode])
|
||||
|
||||
const setMode = useCallback((nextMode: ThemeMode) => {
|
||||
setModeState(nextMode)
|
||||
}, [])
|
||||
|
||||
return { mode, resolvedTheme, setMode }
|
||||
}
|
||||
Reference in New Issue
Block a user