Files
MeBox/web/src/pages/LibrariesPageSections.tsx
T
truewhile c723664e46 优化
2026-09-16 18:34:24 +08:00

245 lines
8.4 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Link } from 'react-router-dom'
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
import type { LibraryPreview } from './librariesPageModel'
// 返回媒体库列表时最多一次性解锁的货架数量:滚动到很深的位置后回来
// 仍能落到原处,同时避免一次渲染过多货架。上限之外的继续按需解锁。
const MAX_RESTORED_SHELVES = 15
export function LibrariesHeader({
previewCount,
total,
isAdmin,
repairMsg,
repairEpisodeArtwork,
repairing,
sortField,
sortOrder,
onSortChange,
onRepairEpisodeArtworkChange,
onRepairRescrape,
onManageLibraries,
}: {
previewCount: number
total: number
isAdmin: boolean
repairMsg: string
repairEpisodeArtwork: boolean
repairing: boolean
sortField: LibraryListSortField
sortOrder: LibraryListSortOrder
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
onRepairEpisodeArtworkChange: (value: boolean) => void
onRepairRescrape: () => void
onManageLibraries: () => void
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between sm:gap-4">
<div>
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">媒体库</h1>
<p className="mt-1 text-xs text-ink-50 sm:text-sm">
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
</p>
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
{isAdmin && (
<>
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={onRepairEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-9 sm:h-10 text-xs sm:text-sm"
/>
<button
type="button"
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
>
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link
to="/scraper/queue"
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
title="查看正在进行的刮削任务与进度"
>
<Sparkles size={14} className="text-brand-500" />
<span>刮削队列</span>
</Link>
<button
type="button"
onClick={onManageLibraries}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
>
管理媒体库
</button>
</>
)}
</div>
</div>
)
}
export function LibrariesEmptyState({ isAdmin = false }: { isAdmin?: boolean }) {
return (
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] py-24 text-center">
<LibraryIcon className="mb-4 h-12 w-12 text-[var(--app-muted)]" />
<p className="text-sm text-[var(--app-muted)]">
{isAdmin ? '暂无媒体库,请到管理后台添加目录。' : '暂无可用媒体库。'}
</p>
</div>
)
}
export function LibrariesContent({
previews,
pinnedIds,
onTogglePin,
onNeedPreviews,
}: {
previews: LibraryPreview[]
pinnedIds: string[]
onTogglePin: (libraryId: string) => void
onNeedPreviews?: (ids: string[], limit?: number) => void
}) {
// 下方媒体库货架继续按需解锁:首屏先展示前 3 个,滚动接近底部再加载 2 个。
const INITIAL_SHELVES = 3
const STEP_SHELVES = 2
// 货架解锁数量同样跨路由记忆:进入媒体库详情再返回不会从头解锁。
const [visibleCount, setVisibleCount] = useRememberedListPosition(
'libraries-shelves',
INITIAL_SHELVES,
MAX_RESTORED_SHELVES,
)
const sentinelRef = useRef<HTMLButtonElement | null>(null)
const userScrolledRef = useRef(false)
const revealMoreShelves = useCallback(() => {
setVisibleCount((prev) => {
if (prev >= previews.length) return prev
return Math.min(prev + STEP_SHELVES, previews.length)
})
}, [previews.length, setVisibleCount])
useEffect(() => {
const currentTargets = previews.slice(0, visibleCount).map((preview) => preview.library.id)
onNeedPreviews?.(currentTargets, 10)
}, [previews, visibleCount, onNeedPreviews])
useEffect(() => {
const scrollParent = document.getElementById('app-main-scroll')
if (!scrollParent) return
const handleCheckBottom = () => {
if (scrollParent.scrollTop <= 0) return
userScrolledRef.current = true
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
if (remaining < 600) {
revealMoreShelves()
}
}
scrollParent.addEventListener('scroll', handleCheckBottom, { passive: true })
const sentinel = sentinelRef.current
let observer: IntersectionObserver | null = null
if (sentinel) {
observer = new IntersectionObserver(
(entries) => {
const [entry] = entries
if (entry?.isIntersecting && userScrolledRef.current) {
revealMoreShelves()
}
},
{
root: scrollParent,
rootMargin: '600px 0px',
threshold: 0,
},
)
observer.observe(sentinel)
}
return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) observer.disconnect()
}
}, [revealMoreShelves, visibleCount, previews.length])
const visiblePreviews = useMemo(
() => previews.slice(0, visibleCount),
[previews, visibleCount],
)
const libraries = useMemo(() => previews.map((preview) => preview.library), [previews])
const libraryData = useMemo(
() =>
Object.fromEntries(
previews.map((preview) => [
preview.library.id,
{ cards: preview.cards, items: [], total: preview.total },
]),
),
[previews],
)
const libraryCounts = useMemo(
() =>
Object.fromEntries(
previews.map((preview) => [preview.library.id, preview.total]),
),
[previews],
)
return (
<>
<HomeLibrariesSection
libraries={libraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
onNeedPreviews={onNeedPreviews}
pinnedIds={pinnedIds}
onTogglePin={onTogglePin}
showAllLink={false}
title="媒体库入口"
/>
{visiblePreviews.length > 0 && (
<section className="space-y-10">
{visiblePreviews.map((preview) => (
<div key={preview.library.id} className="animate-page-in">
<HomeLibraryRowSection
library={preview.library}
cards={preview.cards}
/>
</div>
))}
{visibleCount < previews.length && (
<button
ref={sentinelRef}
type="button"
onClick={revealMoreShelves}
className="flex h-10 w-full items-center justify-center py-2 opacity-60 transition-opacity hover:opacity-100"
>
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
<span>加载更多媒体库货架…</span>
</div>
</button>
)}
</section>
)}
</>
)
}