mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
245 lines
8.4 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|