From ac6249cbc3a061c8f574875b99387f7b389fa30c Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 24 Jun 2026 13:28:38 +0800 Subject: [PATCH] feat(opds): show groups as horizontal carousels when 2+ groups (#4750) (#4755) Feeds that list several groups previously rendered each group as a full grid, which made scrolling past many groups tedious. When a feed has two or more groups, render each group's items in a compact horizontal carousel, matching what Thorium does. Each carousel is a horizontally virtualized react-virtuoso list, so only the covers in view are mounted and fetched; off-screen covers load lazily as the row is scrolled. Scroll arrows page through by index and stay centered on the cover artwork. Book items also get rounded covers (matching the library bookshelf) and drop the inline acquisition badge, which remains on the publication detail page. Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/__tests__/app/opds/feed-view.test.tsx | 96 +++++++++++++++ .../src/app/opds/components/FeedView.tsx | 104 +++++++++++----- .../src/app/opds/components/GroupCarousel.tsx | 115 ++++++++++++++++++ .../app/opds/components/PublicationCard.tsx | 34 +----- apps/readest-app/src/styles/globals.css | 9 ++ 5 files changed, 295 insertions(+), 63 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/opds/feed-view.test.tsx create mode 100644 apps/readest-app/src/app/opds/components/GroupCarousel.tsx diff --git a/apps/readest-app/src/__tests__/app/opds/feed-view.test.tsx b/apps/readest-app/src/__tests__/app/opds/feed-view.test.tsx new file mode 100644 index 00000000..2913d9c9 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/opds/feed-view.test.tsx @@ -0,0 +1,96 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { OPDSFeed, OPDSPublication } from '@/types/opds'; +import { FeedView } from '@/app/opds/components/FeedView'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +vi.mock('@/components/CachedImage', () => ({ + CachedImage: () =>
, +})); + +// jsdom has no layout, so the real Virtuoso renders nothing. Render every item +// synchronously via itemContent, matching the TOCView/BooknoteView test mocks. +vi.mock('react-virtuoso', async () => { + const React = await import('react'); + return { + Virtuoso: React.forwardRef( + ( + { + totalCount, + itemContent, + }: { totalCount: number; itemContent: (index: number) => React.ReactNode }, + _ref: React.Ref, + ) => ( +
+ {Array.from({ length: totalCount }, (_, index) => ( +
{itemContent(index)}
+ ))} +
+ ), + ), + }; +}); + +const pub = (title: string): OPDSPublication => ({ + metadata: { title }, + links: [], + images: [], +}); + +const feedWithGroups = (groupTitles: string[]): OPDSFeed => ({ + metadata: { title: 'Catalog' }, + links: [], + groups: groupTitles.map((title) => ({ + metadata: { title }, + links: [], + publications: [pub(`${title} A`), pub(`${title} B`)], + })), +}); + +const renderFeed = (feed: OPDSFeed, onPublicationSelect = vi.fn()) => + render( + new URL(href, base).toString()} + onNavigate={vi.fn()} + onPublicationSelect={onPublicationSelect} + onGenerateCachedImageUrl={vi.fn(async (url: string) => url)} + isOPDSCatalog={() => true} + />, + ); + +describe('FeedView groups', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('renders each group as a horizontal carousel when there are two or more groups', () => { + renderFeed(feedWithGroups(['Popular', 'Recent'])); + + // One carousel per group, not a vertical grid. + expect(screen.getAllByTestId('group-carousel')).toHaveLength(2); + expect(screen.getByText('Popular A')).toBeTruthy(); + expect(screen.getByText('Recent B')).toBeTruthy(); + }); + + it('does not use a carousel when there is only one group', () => { + renderFeed(feedWithGroups(['Only Group'])); + + expect(screen.queryByTestId('group-carousel')).toBeNull(); + // Publications still render in the (grid) layout. + expect(screen.getByText('Only Group A')).toBeTruthy(); + }); + + it('selects the right publication when a carousel item is clicked', () => { + const onPublicationSelect = vi.fn(); + renderFeed(feedWithGroups(['Popular', 'Recent']), onPublicationSelect); + + fireEvent.click(screen.getByText('Recent A')); + expect(onPublicationSelect).toHaveBeenCalledWith(1, 0); + }); +}); diff --git a/apps/readest-app/src/app/opds/components/FeedView.tsx b/apps/readest-app/src/app/opds/components/FeedView.tsx index 85009f9b..01fe5838 100644 --- a/apps/readest-app/src/app/opds/components/FeedView.tsx +++ b/apps/readest-app/src/app/opds/components/FeedView.tsx @@ -7,6 +7,7 @@ import { useTranslation } from '@/hooks/useTranslation'; import { OPDSFeed, OPDSGenericLink } from '@/types/opds'; import { PublicationCard } from './PublicationCard'; import { NavigationCard } from './NavigationCard'; +import { GroupCarousel } from './GroupCarousel'; import { groupByArray } from '../utils/opdsUtils'; interface FeedViewProps { @@ -42,6 +43,11 @@ export function FeedView({ const hasFacets = feed.facets && feed.facets.length > 0; + // When a feed lists several groups, full grids make scrolling past them + // tedious. Render each group as a compact horizontal carousel instead so the + // page stays short and groups are easy to skim (readest issue #4750). + const useCarousel = (feed.groups?.length ?? 0) >= 2; + const handlePaginationClick = (links?: OPDSGenericLink[]) => { if (links && links.length > 0) { const url = resolveURL(links[0]?.href || '', baseURL); @@ -172,10 +178,14 @@ export function FeedView({ {/* Groups */} {feed.groups?.map((group, groupIndex: number) => ( -
+
{group.metadata && ( -
-

{group.metadata.title}

+
+

+ {group.metadata.title} +

{group.links && group.links.length > 0 && (
)} - {group.navigation && ( -
- {group.navigation.map((item, itemIndex: number) => ( - - ))} -
- )} + {group.navigation && + (useCarousel ? ( + ( +
+ +
+ )} + /> + ) : ( +
+ {group.navigation.map((item, itemIndex: number) => ( + + ))} +
+ ))} - {group.publications && ( -
- {group.publications.map((pub, itemIndex: number) => ( - onPublicationSelect(groupIndex, itemIndex)} - resolveURL={resolveURL} - onGenerateCachedImageUrl={onGenerateCachedImageUrl} - /> - ))} -
- )} + {group.publications && + (useCarousel ? ( + ( +
+ onPublicationSelect(groupIndex, itemIndex)} + resolveURL={resolveURL} + onGenerateCachedImageUrl={onGenerateCachedImageUrl} + /> +
+ )} + /> + ) : ( +
+ {group.publications.map((pub, itemIndex: number) => ( + onPublicationSelect(groupIndex, itemIndex)} + resolveURL={resolveURL} + onGenerateCachedImageUrl={onGenerateCachedImageUrl} + /> + ))} +
+ ))}
))} diff --git a/apps/readest-app/src/app/opds/components/GroupCarousel.tsx b/apps/readest-app/src/app/opds/components/GroupCarousel.tsx new file mode 100644 index 00000000..51d17a86 --- /dev/null +++ b/apps/readest-app/src/app/opds/components/GroupCarousel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; +import { MdChevronLeft, MdChevronRight } from 'react-icons/md'; +import { useTranslation } from '@/hooks/useTranslation'; + +interface GroupCarouselProps { + count: number; + itemContent: (index: number) => React.ReactNode; + // Initial row height (px) so the list renders before the real item height is + // measured, avoiding a layout flash. + defaultRowHeight: number; + // Center the scroll arrows on the cover image rather than the whole card. + coverCentered?: boolean; +} + +export function GroupCarousel({ + count, + itemContent, + defaultRowHeight, + coverCentered = false, +}: GroupCarouselProps) { + const _ = useTranslation(); + const virtuosoRef = useRef(null); + const scrollerRef = useRef(null); + const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); + const [showLeftArrow, setShowLeftArrow] = useState(false); + const [showRightArrow, setShowRightArrow] = useState(false); + const [rowHeight, setRowHeight] = useState(defaultRowHeight); + // Vertical center of the cover (px from the carousel top). Cards carry a + // title/author below the cover, so centering on the artwork keeps the arrows + // visually balanced. Null falls back to centering on the whole row. + const [coverCenter, setCoverCenter] = useState(null); + + const measure = () => { + const scroller = scrollerRef.current; + if (!scroller) return; + const item = scroller.querySelector('[data-carousel-item]'); + if (item) { + setRowHeight(item.getBoundingClientRect().height); + } + if (coverCentered) { + const cover = scroller.querySelector('figure'); + if (cover) { + const top = scroller.getBoundingClientRect().top; + const rect = cover.getBoundingClientRect(); + setCoverCenter(rect.top - top + rect.height / 2); + } + } + }; + + // Page through the carousel by index rather than pixels: Virtuoso sizes the + // horizontal track lazily, so a pixel `scrollBy` clamps to the rendered width. + // Aligning the current edge item to the opposite side advances ~one page. + const scrollByPage = (direction: -1 | 1) => { + const { startIndex, endIndex } = rangeRef.current; + if (direction === 1) { + virtuosoRef.current?.scrollToIndex({ + index: Math.min(count - 1, endIndex), + align: 'start', + behavior: 'smooth', + }); + } else { + virtuosoRef.current?.scrollToIndex({ + index: Math.max(0, startIndex), + align: 'end', + behavior: 'smooth', + }); + } + }; + + return ( +
+ { + scrollerRef.current = ref as HTMLElement; + }} + rangeChanged={(range) => { + rangeRef.current = range; + }} + atTopStateChange={(atStart) => setShowLeftArrow(!atStart)} + atBottomStateChange={(atEnd) => setShowRightArrow(!atEnd)} + totalListHeightChanged={measure} + /> + {showLeftArrow && ( + + )} + {showRightArrow && ( + + )} +
+ ); +} diff --git a/apps/readest-app/src/app/opds/components/PublicationCard.tsx b/apps/readest-app/src/app/opds/components/PublicationCard.tsx index 6d821bad..99899dba 100644 --- a/apps/readest-app/src/app/opds/components/PublicationCard.tsx +++ b/apps/readest-app/src/app/opds/components/PublicationCard.tsx @@ -1,10 +1,8 @@ 'use client'; import { useMemo } from 'react'; -import { useTranslation } from '@/hooks/useTranslation'; import { CachedImage } from '@/components/CachedImage'; import { OPDSPublication, REL } from '@/types/opds'; -import { groupByArray } from '../utils/opdsUtils'; interface PublicationCardProps { publication: OPDSPublication; @@ -21,12 +19,6 @@ export function PublicationCard({ resolveURL, onGenerateCachedImageUrl, }: PublicationCardProps) { - const _ = useTranslation(); - const linksByRel = useMemo( - () => groupByArray(publication.links, (link) => link.rel), - [publication.links], - ); - const thumbnailImage = useMemo(() => { const thumbnails = publication.images?.filter((img) => REL.THUMBNAIL.some((rel: string) => img.rel?.includes(rel)), @@ -53,28 +45,9 @@ export function PublicationCard({ return authorList.map((a) => (typeof a === 'string' ? a : a?.name)).filter(Boolean); }, [publication.metadata?.author]); - const price = useMemo(() => { - const priceLink = publication.links?.find((link) => link.properties?.price); - if (priceLink?.properties?.price) { - const priceObj = Array.isArray(priceLink.properties.price) - ? priceLink.properties.price[0] - : priceLink.properties.price; - - if (priceObj) { - const { currency, value } = priceObj; - return `${currency ? currency + ' ' : ''}${value}`; - } - } - if (linksByRel.has(REL.ACQ + '/open-access')) { - return _('Open Access'); - } - return null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [publication.links, linksByRel]); - return (
-
+
0 && (

{authors.join(', ')}

)} - {price && ( -
-
{price}
-
- )}
); diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index 32c07490..806d93f8 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -551,6 +551,15 @@ input[type='range'].slider-input { background-color: theme('colors.base-100') !important; } +/* Hide the horizontal scrollbar on the virtualized OPDS group carousels. */ +.no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} + [data-eink='true'] input.search-input, [data-eink='true'] input.search-input:focus { border-width: 1px !important;