Skip to content

Enhance book clippings UI with sorting, view modes, and pagination#179

Merged
AnnatarHe merged 1 commit intomasterfrom
claude/redesign-book-detail-page-nUtWX
Apr 14, 2026
Merged

Enhance book clippings UI with sorting, view modes, and pagination#179
AnnatarHe merged 1 commit intomasterfrom
claude/redesign-book-detail-page-nUtWX

Conversation

@AnnatarHe
Copy link
Copy Markdown
Member

@AnnatarHe AnnatarHe commented Apr 14, 2026

Summary

Refactored the book clippings display to provide users with better control over how they browse clippings. Added client-side sorting (newest/oldest), view mode toggle (masonry/list), improved pagination with cursor-based loading, and a dedicated toolbar with visual feedback.

Key Changes

  • New Components:

    • BookClippingsToolbar: Segmented controls for sort order and view mode with clipping count display
    • BookClippingCard: Refactored clipping display with density modes (default/compact) and improved styling
    • InfiniteScrollFooter: Loading states and end-of-list indicator with manual load-more button
  • Pagination Improvements:

    • Switched from offset-based to cursor-based pagination (using lastId)
    • Changed page size constant to BOOK_CLIPPINGS_PAGE_SIZE = 12
    • Implemented proper deduplication of merged items using uniqueById utility
    • Added loading state management with loadingRef and loadingMore state
  • Sorting & Filtering:

    • Client-side sorting by clipping ID (newest/oldest) with instant UI response
    • Merged and deduplicated server-fetched and extra-loaded items
    • Memoized render list to prevent unnecessary re-sorts
  • View Modes:

    • Masonry layout (default, responsive columns)
    • List layout (single column, compact density)
    • Dynamic column gutter adjustment based on view mode
  • UX Enhancements:

    • Empty state with icon and localized messaging
    • Improved loading indicators and footer states
    • Better visual hierarchy with toolbar summary showing loaded/total counts
    • Refined card styling with hover effects and dark mode support
  • Localization:

    • Added translation keys for toolbar, loading states, empty state, and metadata (page numbers)
    • Supported in English, Korean, and Simplified Chinese
  • Schema Updates:

    • Updated GraphQL query to use Pagination instead of PaginationLegacy
    • Exported BOOK_CLIPPINGS_PAGE_SIZE from content component for server-side initial query
  • Skeleton Updates:

    • Enhanced loading skeleton to match new toolbar and card layouts

https://claude.ai/code/session_011YmdsaXCz6KAWRPrfeFWL1


Open with Devin

Redesign the book detail page's clippings section for clarity, elegance, and
performance on large libraries.

- Switch book query pagination from offset-based PaginationLegacy to
  cursor-based Pagination ({limit, lastId}), aligning with clippingList
- New BookClippingCard drops the redundant book title; leads with content,
  surfaces pageAt as a pill and createdAt as a subtle footer, adds a soft
  left-edge quote accent
- New toolbar above the grid: live count summary, sort toggle (newest/oldest,
  client-side on loaded items), and view switch (masonry / compact list)
- New InfiniteScrollFooter provides load-more fallback, loading spinner, and
  end-of-list affordance; works alongside masonic's auto-loader
- Refreshed skeleton and empty state match the new layout; glassmorphism
  + blue-400 primary held consistent across light/dark and mobile/desktop
- i18n keys added for en, zhCN, ko

https://claude.ai/code/session_011YmdsaXCz6KAWRPrfeFWL1
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 14, 2026

Code Review

Overall this is a well-structured feature addition. The component decomposition is clean, the accessibility attributes are thoughtful, and the localization coverage is solid. A few things worth addressing before merging:


Bugs / Correctness

1. Missing Japanese locale (ja)

src/locales/en/book.json, ko, and zhCN are all updated, but src/locales/ja/book.json is not. The CLAUDE.md lists Japanese as a supported language. Missing keys will silently fall back to English, but it's better to keep all locales in sync.

2. Sorting by id instead of createdAt

// content.tsx
copy.sort((a, b) => {
  const delta = (b.id as number) - (a.id as number)
  return sort === 'newest' ? delta : -delta
})

IDs don't reliably represent insertion order — bulk imports, migrations, or any out-of-order writes can make ID ordering diverge from actual creation time. Since the Clipping type already exposes createdAt (and BookClippingCard even formats it), sorting on createdAt would be semantically correct:

const aTime = new Date(a.createdAt as string).getTime()
const bTime = new Date(b.createdAt as string).getTime()
return sort === 'newest' ? bTime - aTime : aTime - bTime

3. as unknown as string on dateTime

// book-clipping-card.tsx
<time dateTime={item.createdAt as unknown as string}>

This double cast suggests the GraphQL scalar type for createdAt isn't string in the generated types. The dateTime attribute should be an ISO-8601 string — if dayjs(item.createdAt) is already used to produce the display date, use the same .toISOString() for the dateTime attribute instead of casting:

const iso = item.createdAt ? dayjs(item.createdAt).toISOString() : undefined
// ...
<time dateTime={iso}>

Performance

4. Masonry full remount on sort/view change

<Masonry
  key={`${view}-${sort}-${columnCount}`}
  ...
/>

The comment acknowledges this forces a remount, but the side-effect is that every item re-animates from scratch on sort or view toggle, causing a visible flash. columnCount is already driven by view, so sort doesn't affect layout — it only reorders items. Consider omitting sort from the key so layout changes still remount but re-sorting only updates item order without a full remount:

key={`${view}-${columnCount}`}

UX

5. Concurrent load triggers in list mode

In list mode, Masonry with columnCount=1 still fires onRender → maybeLoadMore as the user scrolls, while InfiniteScrollFooter simultaneously shows a manual "Load more" button. This means two load paths are racing in list mode. Consider suppressing onRender auto-loading when view === 'list' and relying solely on the footer button, or conversely hiding the footer button when auto-scroll is active.


Minor / Code Quality

6. aria-label strings on toolbar groups are not localized

<div role="group" aria-label="Sort" ...>
<div role="group" aria-label="View" ...>

These are hardcoded English strings. They could use t('...') or at minimum a aria-label i18n key so screen readers in Korean/Chinese get localized labels.

7. notifyOnNetworkStatusChange: true with no networkStatus consumption

const { data: clippingsData, fetchMore } = useQuery<BookQuery>(BookDocument, {
  ...
  notifyOnNetworkStatusChange: true,
})

notifyOnNetworkStatusChange is only useful if networkStatus (or loading) is destructured and acted on. Without consuming it, this option just causes extra renders during fetchMore for no benefit. You can safely remove it, or destructure loading to drive the loadingMore state instead of the separate loadingRef/loadingMore pattern.


What's good

  • Cursor-based pagination (lastId) is the right call here — cleaner than offset drift.
  • loadingRef + state combo correctly prevents re-entrant fetches.
  • uniqueById deduplication handles the initial + extra items merge safely.
  • Accessibility attributes (aria-pressed, role="group", motion-reduce:*) are well applied.
  • Skeleton matches the new toolbar structure.
  • The BOOK_CLIPPINGS_PAGE_SIZE export to sync server and client initial fetch sizes is a nice touch.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the book clippings page to implement infinite scrolling, sorting, and toggleable view modes. It introduces several new UI components, including a toolbar and specialized clipping cards, while updating the GraphQL schema and localizations. Feedback focuses on optimizing performance by hydrating server-side data to the client to avoid redundant requests, improving the loading experience by restoring the skeleton component, and refining the sorting logic and React keys to prevent unnecessary re-renders.

pagination: {
limit: 10,
offset: 0,
limit: BOOK_CLIPPINGS_PAGE_SIZE,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The clippings data is fetched here on the server but is not passed to the BookPageContent component. Consequently, the client-side component performs the exact same query again upon mounting. This redundant network request increases page load time and server load. Consider passing the server-fetched data as props to BookPageContent to initialize the client-side state or hydrate the Apollo cache.

Comment on lines +12 to 13
import InfiniteScrollFooter from '@/components/clipping-item/infinite-scroll-footer'
import { usePageTrack } from '@/hooks/tracke'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The BookPageSkeleton import was removed, but it is still needed to provide a proper loading state while clippingsData is being fetched on the client. Re-adding this import allows for a better user experience than returning null.

Suggested change
import InfiniteScrollFooter from '@/components/clipping-item/infinite-scroll-footer'
import { usePageTrack } from '@/hooks/tracke'
import InfiniteScrollFooter from '@/components/clipping-item/infinite-scroll-footer'
import BookPageSkeleton from './skeleton'
import { usePageTrack } from '@/hooks/tracke'

Comment on lines +71 to +74
copy.sort((a, b) => {
const delta = (b.id as number) - (a.id as number)
return sort === 'newest' ? delta : -delta
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Sorting by id assumes that IDs are strictly chronological and numeric. It is more reliable and accurate to sort by createdAt, which is explicitly intended for time-based ordering. Additionally, using a direct subtraction on a type-asserted number can be fragile if the ID format is actually a string (as indicated by the uniqueById utility type).

    copy.sort((a, b) => {
      const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0
      const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0
      const delta = timeB - timeA
      return sort === 'newest' ? delta : -delta
    })

Comment on lines +124 to 126
if (!clippingsData) {
return null
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Returning null while data is loading results in a blank UI area, which is a regression from the previous implementation that showed a loading skeleton. Since BookPageSkeleton was updated in this PR, it should be used here to improve perceived performance and maintain a consistent layout during the initial client-side fetch.

Suggested change
if (!clippingsData) {
return null
}
// Initial loading state
if (!clippingsData) {
return <BookPageSkeleton />
}

Comment on lines +166 to +167
// Keying on view/sort forces Masonic to recalc layout cleanly.
key={`${view}-${sort}-${columnCount}`}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Including sort in the key prop of the Masonry component causes the entire grid to unmount and remount whenever the sort order changes. This is inefficient and can cause a flickering effect as the list grows. Masonic is designed to handle item reordering efficiently via the items prop and itemKey. The key should only include properties that fundamentally change the layout structure, such as view or columnCount.

Suggested change
// Keying on view/sort forces Masonic to recalc layout cleanly.
key={`${view}-${sort}-${columnCount}`}
// Keying on view/columnCount forces Masonic to recalc layout when the grid structure changes.
key={`${view}-${columnCount}`}

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment thread src/schema/book.graphql
@@ -1,4 +1,4 @@
query book($id: Int!, $pagination: PaginationLegacy!) {
query book($id: Int!, $pagination: Pagination!) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 GraphQL schema.json not updated: book query still expects PaginationLegacy, causing codegen/build failure

The book.graphql query was changed from $pagination: PaginationLegacy! to $pagination: Pagination!, but src/schema/schema.json was not updated and still declares the book query's pagination argument as type PaginationLegacy (src/schema/schema.json:4066-4068). The Pagination type has fields {lastId: Int, limit: Int!} while PaginationLegacy has {limit: Int!, offset: Int!} — they are entirely different input types.

Since codegen.yml uses schema: src/schema/schema.json as its source, running pnpm codegen (which is executed as part of pnpm build) will fail with a GraphQL validation error (VariablesInAllowedPosition rule) because the variable type Pagination! is not compatible with the expected argument type PaginationLegacy. This blocks the production build.

Schema mismatch details

schema.json says the book field expects:

"name": "pagination",
"type": { "kind": "INPUT_OBJECT", "name": "PaginationLegacy" }

But book.graphql declares:

query book($id: Int!, $pagination: Pagination!) {

The fix is to re-introspect the schema from the server (if it's been updated) and commit the new schema.json, or uncomment the live server URL in codegen.yml and run pnpm codegen.

Prompt for agents
The book.graphql query now declares $pagination: Pagination! but the local schema.json still defines the book query's pagination argument as PaginationLegacy. These are different GraphQL input types (Pagination has lastId+limit; PaginationLegacy has offset+limit). This causes graphql-codegen to fail when validating operations against the schema.

To fix: re-introspect the server schema to update src/schema/schema.json. This can be done by temporarily uncommenting the live server URL in codegen.yml and running pnpm codegen, or by downloading the latest introspection result from the GraphQL endpoint. The updated schema.json should be committed alongside this PR so that CI builds succeed.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34285e90c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

} from '@/services/wenqu'

import BookPageContent from './content'
import BookPageContent, { BOOK_CLIPPINGS_PAGE_SIZE } from './content'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move pagination size constant out of the client component

page.tsx is a Server Component, but this import pulls BOOK_CLIPPINGS_PAGE_SIZE from content.tsx, which is marked 'use client'. In App Router, server code can render a client component reference, but it cannot reliably consume runtime values exported by that client module; using this binding in pagination.limit can fail at runtime/build time. Put the constant in a shared non-client module (or duplicate locally) so the server query always receives a plain number.

Useful? React with 👍 / 👎.

Comment thread src/schema/book.graphql
@@ -1,4 +1,4 @@
query book($id: Int!, $pagination: PaginationLegacy!) {
query book($id: Int!, $pagination: Pagination!) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore book query variable type to PaginationLegacy

The checked-in introspection schema still declares Query.book.pagination as PaginationLegacy (src/schema/schema.json line 4067), but this operation now declares $pagination: Pagination!. That makes the document invalid against the repository schema, so pnpm codegen/build validation will fail and requests against the same schema signature will be rejected. Keep this query on PaginationLegacy until the schema update is committed.

Useful? React with 👍 / 👎.

@AnnatarHe AnnatarHe merged commit 707f0c4 into master Apr 14, 2026
12 of 13 checks passed
@AnnatarHe AnnatarHe deleted the claude/redesign-book-detail-page-nUtWX branch April 14, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants