优化books/catalog页面
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import BookCard from '@/components/books/BookCard';
|
||||
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
|
||||
@@ -20,13 +20,9 @@ function CatalogSkeleton() {
|
||||
<div key={index} className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800' />
|
||||
))}
|
||||
</div>
|
||||
<div className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='h-6 w-40 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='mt-3 h-4 w-72 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
</div>
|
||||
<div className='flex gap-3 overflow-x-auto pb-2'>
|
||||
<div className='flex gap-2 overflow-x-auto pb-1'>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className='h-20 min-w-[180px] rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
||||
<div key={index} className='h-10 w-28 shrink-0 rounded-full bg-gray-200 dark:bg-gray-800' />
|
||||
))}
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
@@ -62,18 +58,25 @@ function isMeaningfulNavTitle(title?: string) {
|
||||
}
|
||||
|
||||
export default function BooksCatalogPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const sourceId = searchParams.get('sourceId') || '';
|
||||
const href = searchParams.get('href') || '';
|
||||
const [sources, setSources] = useState<BookSource[]>([]);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState(sourceId);
|
||||
const [selectedHref, setSelectedHref] = useState(href);
|
||||
const [data, setData] = useState<BookCatalogResult | null>(null);
|
||||
const [catalogNavigation, setCatalogNavigation] = useState<BookCatalogResult['navigation']>([]);
|
||||
const [entries, setEntries] = useState<BookListItem[]>([]);
|
||||
const [nextHref, setNextHref] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState('');
|
||||
const [loadingCatalog, setLoadingCatalog] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const loaderRef = useRef<HTMLDivElement | null>(null);
|
||||
const sourceScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeSourceItemRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const navScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const activeNavItemRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const loadedPageHrefsRef = useRef<Set<string>>(new Set());
|
||||
const failedPageHrefsRef = useRef<Set<string>>(new Set());
|
||||
const sourceDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
|
||||
@@ -81,10 +84,60 @@ export default function BooksCatalogPage() {
|
||||
const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
|
||||
const suppressNavClickRef = useRef(false);
|
||||
|
||||
const showImmediateContentLoading = useCallback(() => {
|
||||
setError('');
|
||||
setLoadingCatalog(true);
|
||||
setEntries([]);
|
||||
setNextHref(undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSourceId(sourceId);
|
||||
setSelectedHref(href);
|
||||
setCatalogNavigation([]);
|
||||
}, [sourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedHref(href);
|
||||
}, [href]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceId || !href) return;
|
||||
let cancelled = false;
|
||||
|
||||
const loadRootNavigation = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ sourceId });
|
||||
const res = await fetch(`/api/books/catalog?${params.toString()}`);
|
||||
const json = await res.json();
|
||||
if (!res.ok) return;
|
||||
if (!cancelled) setCatalogNavigation((json as BookCatalogResult).navigation || []);
|
||||
} catch {
|
||||
// 当前分类内容仍可正常展示,根目录分类加载失败时忽略。
|
||||
}
|
||||
};
|
||||
|
||||
void loadRootNavigation();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sourceId, href]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceId || href || catalogNavigation.length === 0) return;
|
||||
const firstNavigationItem = catalogNavigation.find((item) => {
|
||||
const rel = (item.rel || '').toLowerCase();
|
||||
return item.href && rel !== 'next' && rel !== 'previous' && isMeaningfulNavTitle(item.title);
|
||||
});
|
||||
if (!firstNavigationItem?.href) return;
|
||||
setSelectedHref(firstNavigationItem.href);
|
||||
router.replace(`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(firstNavigationItem.href)}`);
|
||||
}, [catalogNavigation, href, router, sourceId]);
|
||||
|
||||
const mergeEntries = useCallback((prev: BookListItem[], next: BookListItem[]) => {
|
||||
const seen = new Set(prev.map((item) => `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`));
|
||||
const merged = [...prev];
|
||||
@@ -106,7 +159,8 @@ export default function BooksCatalogPage() {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setError('');
|
||||
setData(null);
|
||||
setLoadingCatalog(true);
|
||||
if (!normalizedHref) setData(null);
|
||||
setEntries([]);
|
||||
setNextHref(undefined);
|
||||
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
|
||||
@@ -125,6 +179,7 @@ export default function BooksCatalogPage() {
|
||||
setEntries((prev) => mergeEntries(prev, nextData.entries || []));
|
||||
} else {
|
||||
setData(nextData);
|
||||
setCatalogNavigation((prev) => normalizedHref ? (prev.length > 0 ? prev : nextData.navigation || []) : nextData.navigation || []);
|
||||
setEntries(nextData.entries || []);
|
||||
}
|
||||
setNextHref(nextData.nextHref || undefined);
|
||||
@@ -136,6 +191,7 @@ export default function BooksCatalogPage() {
|
||||
}
|
||||
setError(err instanceof Error ? err.message : '获取目录失败');
|
||||
} finally {
|
||||
if (!append) setLoadingCatalog(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [mergeEntries, sourceId]);
|
||||
@@ -279,7 +335,7 @@ export default function BooksCatalogPage() {
|
||||
}, []);
|
||||
|
||||
const navigationItems = useMemo(() => {
|
||||
const items = (data?.navigation || []).filter((item) => {
|
||||
const items = (catalogNavigation || []).filter((item) => {
|
||||
const rel = (item.rel || '').toLowerCase();
|
||||
if (rel === 'next' || rel === 'previous') return false;
|
||||
return isMeaningfulNavTitle(item.title);
|
||||
@@ -292,7 +348,41 @@ export default function BooksCatalogPage() {
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}, [data]);
|
||||
}, [catalogNavigation]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!selectedHref || navigationItems.length === 0) return;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const container = navScrollerRef.current;
|
||||
const activeItem = activeNavItemRef.current;
|
||||
if (!container || !activeItem) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const activeRect = activeItem.getBoundingClientRect();
|
||||
const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2;
|
||||
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [selectedHref, navigationItems]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!selectedSourceId || sources.length === 0) return;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const container = sourceScrollerRef.current;
|
||||
const activeItem = activeSourceItemRef.current;
|
||||
if (!container || !activeItem) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const activeRect = activeItem.getBoundingClientRect();
|
||||
const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2;
|
||||
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [selectedSourceId, sources.length]);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -309,6 +399,7 @@ export default function BooksCatalogPage() {
|
||||
{sources.map((source) => (
|
||||
<Link
|
||||
key={source.id}
|
||||
ref={source.id === selectedSourceId ? activeSourceItemRef : undefined}
|
||||
href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`}
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
@@ -316,27 +407,25 @@ export default function BooksCatalogPage() {
|
||||
if (suppressSourceClickRef.current) {
|
||||
event.preventDefault();
|
||||
suppressSourceClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSelectedSourceId(source.id);
|
||||
setSelectedHref('');
|
||||
showImmediateContentLoading();
|
||||
}}
|
||||
className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === sourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}
|
||||
className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === selectedSourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}
|
||||
>
|
||||
{source.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{error ? <div className='text-sm text-red-500'>{error}</div> : null}
|
||||
{data ? (
|
||||
{data || navigationItems.length > 0 ? (
|
||||
<>
|
||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<h1 className='text-lg font-semibold'>{data.title}</h1>
|
||||
{data.subtitle ? <p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{data.subtitle}</p> : null}
|
||||
</section>
|
||||
{navigationItems.length > 0 ? (
|
||||
<section className='space-y-3'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>目录</div>
|
||||
<div
|
||||
ref={navScrollerRef}
|
||||
className='flex gap-3 overflow-x-auto pb-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
||||
className='flex flex-nowrap gap-2 overflow-x-auto pb-1 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
||||
onPointerDown={handleNavPointerDown}
|
||||
onPointerMove={handleNavPointerMove}
|
||||
onPointerUp={handleNavPointerUp}
|
||||
@@ -347,6 +436,7 @@ export default function BooksCatalogPage() {
|
||||
{navigationItems.map((item, index) => (
|
||||
<Link
|
||||
key={`${item.href}-${index}`}
|
||||
ref={item.href === selectedHref ? activeNavItemRef : undefined}
|
||||
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
@@ -354,20 +444,25 @@ export default function BooksCatalogPage() {
|
||||
if (suppressNavClickRef.current) {
|
||||
event.preventDefault();
|
||||
suppressNavClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSelectedHref(item.href);
|
||||
showImmediateContentLoading();
|
||||
}}
|
||||
className='min-w-[180px] rounded-2xl border border-gray-200 bg-white p-4 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950'
|
||||
className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${item.href === selectedHref ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}
|
||||
>
|
||||
<div className='line-clamp-2 font-medium'>{item.title.trim()}</div>
|
||||
<div className='mt-2 text-xs text-gray-500 dark:text-gray-400'>点击进入子目录</div>
|
||||
{item.title.trim()}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{loadingCatalog ? (
|
||||
<LoadingMoreSkeleton />
|
||||
) : (
|
||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}-${item.detailHref || item.acquisitionLinks[0]?.href || ''}`} item={item} href={makeHref(sourceId, item)} onNavigate={() => cacheBookListItem(item)} />)}
|
||||
</section>
|
||||
)}
|
||||
{loadingMore ? <LoadingMoreSkeleton /> : null}
|
||||
{!loadingMore && nextHref ? <div ref={loaderRef} className='h-8 w-full' /> : null}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user