-
Notifications
You must be signed in to change notification settings - Fork 0
Enhance book clippings UI with sorting, view modes, and pagination #179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<BookQuery['book']['clippings']>[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<BookClippingsItem[]>([]) | ||||||||||||||||
| const loadingRef = useRef(false) | ||||||||||||||||
|
|
||||||||||||||||
| const { data: clippingsData, fetchMore } = useQuery<BookQuery>(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<BookClippingsItem>([...initialClippings, ...extraItems]), | ||||||||||||||||
| [initialClippings, extraItems] | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| const [sort, setSort] = useState<ClippingsSortOrder>('newest') | ||||||||||||||||
| const [view, setView] = useState<ClippingsViewMode>('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 | ||||||||||||||||
| }) | ||||||||||||||||
|
Comment on lines
+71
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorting by |
||||||||||||||||
| 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 <BookPageSkeleton /> | ||||||||||||||||
| // Initial loading state — rendered by the Suspense skeleton instead. | ||||||||||||||||
| if (!clippingsData) { | ||||||||||||||||
| return null | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+124
to
126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Returning
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| // Empty state | ||||||||||||||||
| if (totalCount === 0) { | ||||||||||||||||
| return ( | ||||||||||||||||
| <div className="mt-4 flex flex-col items-center justify-center rounded-2xl border border-slate-200/60 bg-white/60 px-6 py-16 text-center backdrop-blur-xl dark:border-slate-700/50 dark:bg-slate-800/50"> | ||||||||||||||||
| <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-400 dark:bg-blue-400/10 dark:text-blue-300"> | ||||||||||||||||
| <BookOpen size={28} /> | ||||||||||||||||
| </div> | ||||||||||||||||
| <h3 className="mb-1 text-lg font-semibold text-slate-800 dark:text-slate-100"> | ||||||||||||||||
| {t('app.book.clippings.empty.title')} | ||||||||||||||||
| </h3> | ||||||||||||||||
| <p className="max-w-sm text-sm text-slate-500 dark:text-slate-400"> | ||||||||||||||||
| {t('app.book.clippings.empty.description')} | ||||||||||||||||
| </p> | ||||||||||||||||
| </div> | ||||||||||||||||
| ) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const footerState: 'loading' | 'hasMore' | 'end' = loadingMore | ||||||||||||||||
| ? 'loading' | ||||||||||||||||
| : hasMore | ||||||||||||||||
| ? 'hasMore' | ||||||||||||||||
| : 'end' | ||||||||||||||||
|
|
||||||||||||||||
| const columnCount = view === 'list' ? 1 : masonaryColumnCount | ||||||||||||||||
| const columnGutter = view === 'list' ? 0 : 24 | ||||||||||||||||
|
|
||||||||||||||||
| return ( | ||||||||||||||||
| <Masonry | ||||||||||||||||
| items={(clippingsData?.book.clippings ?? []) as Clipping[]} | ||||||||||||||||
| columnCount={masonaryColumnCount} | ||||||||||||||||
| columnGutter={30} | ||||||||||||||||
| onRender={maybeLoadMore} | ||||||||||||||||
| render={(row) => { | ||||||||||||||||
| const clipping = row.data | ||||||||||||||||
| return ( | ||||||||||||||||
| <ClippingItem | ||||||||||||||||
| item={clipping} | ||||||||||||||||
| domain={domain} | ||||||||||||||||
| book={bookData} | ||||||||||||||||
| key={clipping.id} | ||||||||||||||||
| inAppChannel={IN_APP_CHANNEL.clippingFromBook} | ||||||||||||||||
| /> | ||||||||||||||||
| ) | ||||||||||||||||
| }} | ||||||||||||||||
| /> | ||||||||||||||||
| <section aria-label="clippings" className="mt-2"> | ||||||||||||||||
| <BookClippingsToolbar | ||||||||||||||||
| totalCount={totalCount} | ||||||||||||||||
| loadedCount={mergedItems.length} | ||||||||||||||||
| sort={sort} | ||||||||||||||||
| onSortChange={setSort} | ||||||||||||||||
| view={view} | ||||||||||||||||
| onViewChange={setView} | ||||||||||||||||
| /> | ||||||||||||||||
|
|
||||||||||||||||
| <Masonry | ||||||||||||||||
| // Keying on view/sort forces Masonic to recalc layout cleanly. | ||||||||||||||||
| key={`${view}-${sort}-${columnCount}`} | ||||||||||||||||
|
Comment on lines
+166
to
+167
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Including
Suggested change
|
||||||||||||||||
| items={renderList} | ||||||||||||||||
| columnCount={columnCount} | ||||||||||||||||
| columnGutter={columnGutter} | ||||||||||||||||
| onRender={maybeLoadMore} | ||||||||||||||||
| itemKey={(item: BookClippingsItem) => item.id} | ||||||||||||||||
| render={(row) => { | ||||||||||||||||
| const clipping = row.data as BookClippingsItem | ||||||||||||||||
| return ( | ||||||||||||||||
| <BookClippingCard | ||||||||||||||||
| item={clipping} | ||||||||||||||||
| domain={domain} | ||||||||||||||||
| density={view === 'list' ? 'compact' : 'default'} | ||||||||||||||||
| inAppChannel={IN_APP_CHANNEL.clippingFromBook} | ||||||||||||||||
| /> | ||||||||||||||||
| ) | ||||||||||||||||
| }} | ||||||||||||||||
| /> | ||||||||||||||||
|
|
||||||||||||||||
| <InfiniteScrollFooter state={footerState} onLoadMore={loadMore} /> | ||||||||||||||||
| </section> | ||||||||||||||||
| ) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ import { | |
| wenquRequest, | ||
| } from '@/services/wenqu' | ||
|
|
||
| import BookPageContent from './content' | ||
| import BookPageContent, { BOOK_CLIPPINGS_PAGE_SIZE } from './content' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The clippings data is fetched here on the server but is not passed to the |
||
| }, | ||
| }, | ||
| context: { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Clipping, 'id' | 'content' | 'pageAt' | 'createdAt'> | ||
| 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 ( | ||
| <Link | ||
| href={`/dash/${domain}/clippings/${item.id}?iac=${inAppChannel}`} | ||
| className={`group relative mb-5 block ${className}`} | ||
| > | ||
| <article | ||
| className={[ | ||
| 'relative overflow-hidden rounded-2xl border backdrop-blur-xl transition-all duration-300 ease-out', | ||
| 'border-slate-200/60 bg-white/70 shadow-[0_4px_24px_rgba(15,23,42,0.04)]', | ||
| 'hover:-translate-y-0.5 hover:border-blue-300/60 hover:shadow-[0_12px_40px_rgba(59,130,246,0.15)]', | ||
| 'dark:border-slate-700/50 dark:bg-slate-800/60 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]', | ||
| 'dark:hover:border-blue-400/40 dark:hover:shadow-[0_12px_40px_rgba(59,130,246,0.2)]', | ||
| 'motion-reduce:transition-none motion-reduce:hover:translate-y-0', | ||
| isCompact ? 'p-4 lg:p-5' : 'p-6 lg:p-7', | ||
| ].join(' ')} | ||
| > | ||
| {/* Subtle left quote accent bar */} | ||
| {!isCompact && ( | ||
| <span | ||
| aria-hidden | ||
| className="pointer-events-none absolute top-4 bottom-4 left-0 w-[3px] rounded-r-full bg-gradient-to-b from-blue-400/70 via-blue-400/40 to-transparent opacity-70 transition-opacity duration-300 group-hover:opacity-100 dark:from-blue-400/80 dark:via-blue-400/40" | ||
| /> | ||
| )} | ||
|
|
||
| {/* Top row: page pill + date */} | ||
| {(hasPage || date) && ( | ||
| <header | ||
| className={`flex items-center justify-between gap-2 ${isCompact ? 'mb-2' : 'mb-3'}`} | ||
| > | ||
| {hasPage ? ( | ||
| <span className="inline-flex items-center rounded-full border border-blue-200/60 bg-blue-50/70 px-2.5 py-0.5 font-mono text-[11px] font-medium tracking-wide text-blue-600 dark:border-blue-400/30 dark:bg-blue-400/10 dark:text-blue-300"> | ||
| {t('app.book.clippings.meta.page', { page: pageLabel })} | ||
| </span> | ||
| ) : ( | ||
| <span /> | ||
| )} | ||
| {date && ( | ||
| <time | ||
| dateTime={item.createdAt as unknown as string} | ||
| className="text-[11px] font-medium tracking-wide text-slate-400 tabular-nums dark:text-slate-500" | ||
| > | ||
| {date} | ||
| </time> | ||
| )} | ||
| </header> | ||
| )} | ||
|
|
||
| {/* Body */} | ||
| <ClippingContent | ||
| content={item.content} | ||
| maxLines={isCompact ? 4 : 0} | ||
| className={[ | ||
| 'font-lxgw text-slate-800 dark:text-slate-200', | ||
| isCompact | ||
| ? 'text-base leading-relaxed lg:text-lg' | ||
| : 'text-lg leading-relaxed lg:text-xl', | ||
| ].join(' ')} | ||
| /> | ||
| </article> | ||
| </Link> | ||
| ) | ||
| } | ||
|
|
||
| export default BookClippingCard |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
BookPageSkeletonimport was removed, but it is still needed to provide a proper loading state whileclippingsDatais being fetched on the client. Re-adding this import allows for a better user experience than returningnull.