diff --git a/src/app/dash/[userid]/book/[bookid]/content.tsx b/src/app/dash/[userid]/book/[bookid]/content.tsx index 3fa69894..319d01fb 100644 --- a/src/app/dash/[userid]/book/[bookid]/content.tsx +++ b/src/app/dash/[userid]/book/[bookid]/content.tsx @@ -1,16 +1,26 @@ 'use client' import { useQuery } from '@apollo/client/react' +import { BookOpen } from 'lucide-react' import { Masonry, useInfiniteLoader } from 'masonic' -import { useRef } from 'react' +import { useMemo, useRef, useState } from 'react' -import ClippingItem from '@/components/clipping-item/clipping-item' +import BookClippingCard from '@/components/clipping-item/book-clipping-card' +import BookClippingsToolbar, { + type ClippingsSortOrder, + type ClippingsViewMode, +} from '@/components/clipping-item/book-clippings-toolbar' +import InfiniteScrollFooter from '@/components/clipping-item/infinite-scroll-footer' import { usePageTrack } from '@/hooks/tracke' import { useMasonaryColumnCount } from '@/hooks/use-screen-size' -import { BookDocument, type BookQuery, type Clipping } from '@/schema/generated' +import { useTranslation } from '@/i18n/client' +import { BookDocument, type BookQuery } from '@/schema/generated' import { IN_APP_CHANNEL } from '@/services/channel' import type { WenquBook } from '@/services/wenqu' +import { uniqueById } from '@/utils/array' -import BookPageSkeleton from './skeleton' +export const BOOK_CLIPPINGS_PAGE_SIZE = 12 + +type BookClippingsItem = NonNullable[number] type BookPageContentProps = { userid: string @@ -19,67 +29,162 @@ type BookPageContentProps = { function BookPageContent(props: BookPageContentProps) { const { userid: domain, book: bookData } = props + const { t } = useTranslation(undefined, 'book') usePageTrack('book', { bookId: bookData.id, }) - const hasMore = useRef(true) + const [extraItems, setExtraItems] = useState([]) + const loadingRef = useRef(false) + const { data: clippingsData, fetchMore } = useQuery(BookDocument, { variables: { id: bookData.doubanId, pagination: { - limit: 10, - offset: 0, + limit: BOOK_CLIPPINGS_PAGE_SIZE, }, }, + notifyOnNetworkStatusChange: true, }) - const masonaryColumnCount = useMasonaryColumnCount() - const maybeLoadMore = useInfiniteLoader( - (_, __, currentItems) => { - if (!hasMore.current) { - return Promise.reject(1) - } - return fetchMore({ + const initialClippings = (clippingsData?.book.clippings ?? []) as + | BookClippingsItem[] + | never[] + const totalCount = clippingsData?.book.clippingsCount ?? 0 + + // Merge + dedupe server page + loaded pages. + const mergedItems = useMemo( + () => uniqueById([...initialClippings, ...extraItems]), + [initialClippings, extraItems] + ) + + const [sort, setSort] = useState('newest') + const [view, setView] = useState('masonry') + + // Client-side sort of loaded items. A backend `orderBy` arg is a follow-up; + // until then this gives the user instant control over loaded rows. + const renderList = useMemo(() => { + if (mergedItems.length < 2) { + return mergedItems + } + const copy = [...mergedItems] + copy.sort((a, b) => { + const delta = (b.id as number) - (a.id as number) + return sort === 'newest' ? delta : -delta + }) + return copy + }, [mergedItems, sort]) + + const hasMore = mergedItems.length < totalCount + const [loadingMore, setLoadingMore] = useState(false) + + const loadMore = async () => { + if (!hasMore || loadingRef.current || mergedItems.length === 0) { + return + } + loadingRef.current = true + setLoadingMore(true) + try { + const lastId = mergedItems[mergedItems.length - 1].id as number + const resp = await fetchMore({ variables: { id: bookData.doubanId, pagination: { - limit: 10, - offset: currentItems.length, + limit: BOOK_CLIPPINGS_PAGE_SIZE, + lastId, }, }, }) + const next = (resp.data?.book.clippings ?? []) as BookClippingsItem[] + if (next.length > 0) { + setExtraItems((prev) => [...prev, ...next]) + } + } finally { + loadingRef.current = false + setLoadingMore(false) + } + } + + const masonaryColumnCount = useMasonaryColumnCount() + const maybeLoadMore = useInfiniteLoader( + async () => { + if (!hasMore) { + return + } + await loadMore() }, { isItemLoaded: (index, items) => !!items[index], threshold: 3, - totalItems: clippingsData?.book.clippingsCount, + totalItems: totalCount, } ) - if (!bookData || !clippingsData) { - return + // Initial loading state — rendered by the Suspense skeleton instead. + if (!clippingsData) { + return null } + // Empty state + if (totalCount === 0) { + return ( +
+
+ +
+

+ {t('app.book.clippings.empty.title')} +

+

+ {t('app.book.clippings.empty.description')} +

+
+ ) + } + + const footerState: 'loading' | 'hasMore' | 'end' = loadingMore + ? 'loading' + : hasMore + ? 'hasMore' + : 'end' + + const columnCount = view === 'list' ? 1 : masonaryColumnCount + const columnGutter = view === 'list' ? 0 : 24 + return ( - { - const clipping = row.data - return ( - - ) - }} - /> +
+ + + item.id} + render={(row) => { + const clipping = row.data as BookClippingsItem + return ( + + ) + }} + /> + + +
) } diff --git a/src/app/dash/[userid]/book/[bookid]/page.tsx b/src/app/dash/[userid]/book/[bookid]/page.tsx index a2cdd576..ff8413e1 100644 --- a/src/app/dash/[userid]/book/[bookid]/page.tsx +++ b/src/app/dash/[userid]/book/[bookid]/page.tsx @@ -21,7 +21,7 @@ import { wenquRequest, } from '@/services/wenqu' -import BookPageContent from './content' +import BookPageContent, { BOOK_CLIPPINGS_PAGE_SIZE } from './content' type PageProps = { params: Promise<{ bookid: string; userid: string }> @@ -71,8 +71,7 @@ async function Page(props: PageProps) { variables: { id: ~~dbId, pagination: { - limit: 10, - offset: 0, + limit: BOOK_CLIPPINGS_PAGE_SIZE, }, }, context: { diff --git a/src/app/dash/[userid]/book/[bookid]/skeleton.tsx b/src/app/dash/[userid]/book/[bookid]/skeleton.tsx index a7bdeb29..08f93a1d 100644 --- a/src/app/dash/[userid]/book/[bookid]/skeleton.tsx +++ b/src/app/dash/[userid]/book/[bookid]/skeleton.tsx @@ -89,15 +89,30 @@ function BookPageSkeleton() { /> + {/* Toolbar skeleton */} +
+
+
+
+
+
+
+ {/* Clippings grid skeleton */}
{new Array(6).fill(1).map((_, i) => (
-
-
+ +
+
+
+
diff --git a/src/components/clipping-item/book-clipping-card.tsx b/src/components/clipping-item/book-clipping-card.tsx new file mode 100644 index 00000000..690ddf9e --- /dev/null +++ b/src/components/clipping-item/book-clipping-card.tsx @@ -0,0 +1,97 @@ +import dayjs from 'dayjs' +import Link from 'next/link' + +import { useTranslation } from '@/i18n/client' +import type { Clipping } from '@/schema/generated' +import type { IN_APP_CHANNEL } from '@/services/channel' + +import ClippingContent from '../clipping-content' + +type BookClippingCardProps = { + item: Pick + domain: string + density?: 'default' | 'compact' + inAppChannel: IN_APP_CHANNEL + className?: string +} + +function BookClippingCard(props: BookClippingCardProps) { + const { + item, + domain, + density = 'default', + inAppChannel, + className = '', + } = props + const { t } = useTranslation(undefined, 'book') + + const pageLabel = item.pageAt ? String(item.pageAt).trim() : '' + const hasPage = pageLabel.length > 0 + const date = item.createdAt ? dayjs(item.createdAt).format('YYYY-MM-DD') : '' + + const isCompact = density === 'compact' + + return ( + +
+ {/* Subtle left quote accent bar */} + {!isCompact && ( + + )} + + {/* Top row: page pill + date */} + {(hasPage || date) && ( +
+ {hasPage ? ( + + {t('app.book.clippings.meta.page', { page: pageLabel })} + + ) : ( + + )} + {date && ( + + )} +
+ )} + + {/* Body */} + +
+ + ) +} + +export default BookClippingCard diff --git a/src/components/clipping-item/book-clippings-toolbar.tsx b/src/components/clipping-item/book-clippings-toolbar.tsx new file mode 100644 index 00000000..608bc7e8 --- /dev/null +++ b/src/components/clipping-item/book-clippings-toolbar.tsx @@ -0,0 +1,124 @@ +import { + ArrowDownNarrowWide, + ArrowUpWideNarrow, + LayoutGrid, + List as ListIcon, +} from 'lucide-react' + +import { useTranslation } from '@/i18n/client' + +export type ClippingsSortOrder = 'newest' | 'oldest' +export type ClippingsViewMode = 'masonry' | 'list' + +type BookClippingsToolbarProps = { + totalCount: number + loadedCount: number + sort: ClippingsSortOrder + onSortChange: (next: ClippingsSortOrder) => void + view: ClippingsViewMode + onViewChange: (next: ClippingsViewMode) => void +} + +const baseSegButton = + 'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-200 focus:ring-2 focus:ring-blue-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50' + +function BookClippingsToolbar(props: BookClippingsToolbarProps) { + const { totalCount, loadedCount, sort, onSortChange, view, onViewChange } = + props + const { t } = useTranslation(undefined, 'book') + + return ( +
+ {/* Summary */} +
+ + {t('app.book.clippings.toolbar.count', { count: totalCount })} + + {loadedCount > 0 && loadedCount < totalCount && ( + + · {loadedCount}/{totalCount} + + )} +
+ + {/* Controls */} +
+ {/* Sort segmented toggle */} +
+ + +
+ + {/* View mode toggle */} +
+ + +
+
+
+ ) +} + +export default BookClippingsToolbar diff --git a/src/components/clipping-item/infinite-scroll-footer.tsx b/src/components/clipping-item/infinite-scroll-footer.tsx new file mode 100644 index 00000000..9690de46 --- /dev/null +++ b/src/components/clipping-item/infinite-scroll-footer.tsx @@ -0,0 +1,48 @@ +import { Loader2 } from 'lucide-react' + +import { useTranslation } from '@/i18n/client' + +type InfiniteScrollFooterProps = { + state: 'loading' | 'hasMore' | 'end' + onLoadMore?: () => void +} + +function InfiniteScrollFooter(props: InfiniteScrollFooterProps) { + const { state, onLoadMore } = props + const { t } = useTranslation(undefined, 'book') + + if (state === 'loading') { + return ( +
+ + {t('app.book.clippings.loading')} +
+ ) + } + + if (state === 'hasMore') { + return ( +
+ +
+ ) + } + + return ( +
+
+ + {t('app.book.clippings.end')} + +
+
+ ) +} + +export default InfiniteScrollFooter diff --git a/src/locales/en/book.json b/src/locales/en/book.json index c83a2318..15c95c87 100644 --- a/src/locales/en/book.json +++ b/src/locales/en/book.json @@ -21,6 +21,30 @@ "title": "Summary", "showMore": "Show more", "showLess": "Show less" + }, + "clippings": { + "toolbar": { + "count": "{{count}} clipping", + "count_plural": "{{count}} clippings", + "sort": { + "newest": "Newest", + "oldest": "Oldest" + }, + "view": { + "masonry": "Masonry", + "list": "List" + } + }, + "loadMore": "Load more", + "loading": "Loading more…", + "end": "You've reached the end", + "empty": { + "title": "No clippings yet", + "description": "Clippings from this book will appear here." + }, + "meta": { + "page": "p. {{page}}" + } } } } diff --git a/src/locales/ko/book.json b/src/locales/ko/book.json index 8f16b70f..12bb1b39 100644 --- a/src/locales/ko/book.json +++ b/src/locales/ko/book.json @@ -21,6 +21,30 @@ "title": "요약", "showMore": "열기", "showLess": "닫기" + }, + "clippings": { + "toolbar": { + "count": "{{count}}개의 클리핑", + "count_plural": "{{count}}개의 클리핑", + "sort": { + "newest": "최신순", + "oldest": "오래된순" + }, + "view": { + "masonry": "메이슨리", + "list": "목록" + } + }, + "loadMore": "더 불러오기", + "loading": "불러오는 중…", + "end": "마지막 클리핑입니다", + "empty": { + "title": "아직 클리핑이 없습니다", + "description": "이 책의 클리핑이 여기에 표시됩니다." + }, + "meta": { + "page": "{{page}}쪽" + } } } } diff --git a/src/locales/zhCN/book.json b/src/locales/zhCN/book.json index d7089194..db145e6e 100644 --- a/src/locales/zhCN/book.json +++ b/src/locales/zhCN/book.json @@ -21,6 +21,30 @@ "title": "摘要", "showMore": "展开", "showLess": "闭合" + }, + "clippings": { + "toolbar": { + "count": "{{count}} 条摘录", + "count_plural": "{{count}} 条摘录", + "sort": { + "newest": "最新", + "oldest": "最早" + }, + "view": { + "masonry": "瀑布流", + "list": "列表" + } + }, + "loadMore": "加载更多", + "loading": "加载中…", + "end": "已经到底啦", + "empty": { + "title": "还没有摘录", + "description": "这本书的摘录会出现在这里。" + }, + "meta": { + "page": "第 {{page}} 页" + } } } } diff --git a/src/schema/book.graphql b/src/schema/book.graphql index 0184a40d..a1df1192 100644 --- a/src/schema/book.graphql +++ b/src/schema/book.graphql @@ -1,4 +1,4 @@ -query book($id: Int!, $pagination: PaginationLegacy!) { +query book($id: Int!, $pagination: Pagination!) { book(id: $id, pagination: $pagination) { doubanId startReadingAt