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;