diff --git a/src/app/books/catalog/page.tsx b/src/app/books/catalog/page.tsx index 6f06ea5..046d07d 100644 --- a/src/app/books/catalog/page.tsx +++ b/src/app/books/catalog/page.tsx @@ -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() {
))}
-
-
-
-
-
+
{Array.from({ length: 5 }).map((_, index) => ( -
+
))}
@@ -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([]); + const [selectedSourceId, setSelectedSourceId] = useState(sourceId); + const [selectedHref, setSelectedHref] = useState(href); const [data, setData] = useState(null); + const [catalogNavigation, setCatalogNavigation] = useState([]); const [entries, setEntries] = useState([]); const [nextHref, setNextHref] = useState(undefined); const [error, setError] = useState(''); + const [loadingCatalog, setLoadingCatalog] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const loaderRef = useRef(null); const sourceScrollerRef = useRef(null); + const activeSourceItemRef = useRef(null); const navScrollerRef = useRef(null); + const activeNavItemRef = useRef(null); const loadedPageHrefsRef = useRef>(new Set()); const failedPageHrefsRef = useRef>(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 (
@@ -309,6 +399,7 @@ export default function BooksCatalogPage() { {sources.map((source) => ( event.preventDefault()} @@ -316,58 +407,62 @@ 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} ))}
{error ?
{error}
: null} - {data ? ( + {data || navigationItems.length > 0 ? ( <> -
-

{data.title}

- {data.subtitle ?

{data.subtitle}

: null} -
{navigationItems.length > 0 ? ( -
-
目录
-
- {navigationItems.map((item, index) => ( - event.preventDefault()} - onClick={(event) => { - if (suppressNavClickRef.current) { - event.preventDefault(); - suppressNavClickRef.current = false; - } - }} - 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' - > -
{item.title.trim()}
-
点击进入子目录
- - ))} -
-
+
+ {navigationItems.map((item, index) => ( + event.preventDefault()} + onClick={(event) => { + if (suppressNavClickRef.current) { + event.preventDefault(); + suppressNavClickRef.current = false; + return; + } + setSelectedHref(item.href); + showImmediateContentLoading(); + }} + 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'}`} + > + {item.title.trim()} + + ))} +
) : null} -
- {entries.map((item) => cacheBookListItem(item)} />)} -
+ {loadingCatalog ? ( + + ) : ( +
+ {entries.map((item) => cacheBookListItem(item)} />)} +
+ )} {loadingMore ? : null} {!loadingMore && nextHref ?
: null}