'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 BookCard from '@/components/books/BookCard';
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types';
function makeHref(sourceId: string, item: BookListItem) {
return buildBookDetailPath(sourceId, item.id);
}
function CatalogSkeleton() {
return (
{Array.from({ length: 4 }).map((_, index) => (
))}
{Array.from({ length: 5 }).map((_, index) => (
))}
{Array.from({ length: 12 }).map((_, index) => (
))}
);
}
function LoadingMoreSkeleton() {
return (
{Array.from({ length: 6 }).map((_, index) => (
))}
);
}
function isMeaningfulNavTitle(title?: string) {
const text = (title || '').trim();
return !!text && text !== '目录';
}
export default function BooksCatalogPage() {
const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || '';
const href = searchParams.get('href') || '';
const [sources, setSources] = useState([]);
const [data, setData] = useState(null);
const [entries, setEntries] = useState([]);
const [nextHref, setNextHref] = useState(undefined);
const [error, setError] = useState('');
const [loadingMore, setLoadingMore] = useState(false);
const loaderRef = useRef(null);
const navScrollerRef = useRef(null);
const loadedPageHrefsRef = useRef>(new Set());
const failedPageHrefsRef = useRef>(new Set());
const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
const suppressNavClickRef = useRef(false);
useEffect(() => {
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
}, []);
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];
for (const item of next) {
const key = `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`;
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
return merged;
}, []);
const loadCatalog = useCallback(async (targetHref?: string, append = false) => {
if (!sourceId) return;
const normalizedHref = targetHref || '';
if (append) {
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref) || failedPageHrefsRef.current.has(normalizedHref)) return;
setLoadingMore(true);
} else {
setError('');
setData(null);
setEntries([]);
setNextHref(undefined);
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
failedPageHrefsRef.current = new Set();
}
try {
const params = new URLSearchParams({ sourceId });
if (normalizedHref) params.set('href', normalizedHref);
const res = await fetch(`/api/books/catalog?${params.toString()}`);
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取目录失败');
const nextData = json as BookCatalogResult;
if (append) {
loadedPageHrefsRef.current.add(normalizedHref);
setEntries((prev) => mergeEntries(prev, nextData.entries || []));
} else {
setData(nextData);
setEntries(nextData.entries || []);
}
setNextHref(nextData.nextHref || undefined);
if (!append) setData(nextData);
} catch (err) {
if (append && normalizedHref) {
failedPageHrefsRef.current.add(normalizedHref);
setNextHref(undefined);
}
setError(err instanceof Error ? err.message : '获取目录失败');
} finally {
setLoadingMore(false);
}
}, [mergeEntries, sourceId]);
useEffect(() => {
if (!sourceId) return;
void loadCatalog(href, false);
}, [sourceId, href, loadCatalog]);
useEffect(() => {
const node = loaderRef.current;
if (!node || !nextHref || loadingMore || !data) return;
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry?.isIntersecting && nextHref && !loadingMore) {
void loadCatalog(nextHref, true);
}
}, { rootMargin: '800px 0px' });
observer.observe(node);
return () => observer.disconnect();
}, [data, nextHref, loadingMore, loadCatalog]);
const handleNavPointerDown = useCallback((event: ReactPointerEvent) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const node = navScrollerRef.current;
if (!node) return;
navDragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startScrollLeft: node.scrollLeft,
moved: false,
pointerType: event.pointerType,
};
suppressNavClickRef.current = false;
if (event.pointerType !== 'mouse') {
node.setPointerCapture?.(event.pointerId);
}
}, []);
const handleNavPointerMove = useCallback((event: ReactPointerEvent) => {
const node = navScrollerRef.current;
const dragState = navDragStateRef.current;
if (!node || !dragState || dragState.pointerId !== event.pointerId) return;
const deltaX = event.clientX - dragState.startX;
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
if (Math.abs(deltaX) > moveThreshold) {
dragState.moved = true;
suppressNavClickRef.current = true;
}
node.scrollLeft = dragState.startScrollLeft - deltaX;
}, []);
const handleNavPointerUp = useCallback((event: ReactPointerEvent) => {
const node = navScrollerRef.current;
const dragState = navDragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
if (dragState.moved) {
event.preventDefault();
window.setTimeout(() => {
suppressNavClickRef.current = false;
}, 0);
}
navDragStateRef.current = null;
if (dragState.pointerType !== 'mouse') {
node?.releasePointerCapture?.(event.pointerId);
}
}, []);
const handleNavPointerLeave = useCallback((event: ReactPointerEvent) => {
if (event.pointerType === 'mouse') return;
handleNavPointerUp(event);
}, [handleNavPointerUp]);
const handleNavWheel = useCallback((event: ReactWheelEvent) => {
const node = navScrollerRef.current;
if (!node) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (!delta) return;
node.scrollLeft += delta;
}, []);
const navigationItems = useMemo(() => {
const items = (data?.navigation || []).filter((item) => {
const rel = (item.rel || '').toLowerCase();
if (rel === 'next' || rel === 'previous') return false;
return isMeaningfulNavTitle(item.title);
});
const seen = new Set();
return items.filter((item) => {
const key = `${item.href}::${(item.title || '').trim()}`;
if (!item.href || seen.has(key)) return false;
seen.add(key);
return true;
});
}, [data]);
return (
{sources.map((source) => (
{source.name}
))}
{error ?
{error}
: null}
{data ? (
<>
{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()}
点击进入子目录
))}
) : null}
{entries.map((item) => cacheBookListItem(item)} />)}
{loadingMore ?
: null}
{!loadingMore && nextHref ?
: null}
>
) : !error ?
: null}
);
}