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 && (
-
- )}
);
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;