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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-24 13:28:38 +08:00
committed by GitHub
parent 7d1a60b9ea
commit ac6249cbc3
5 changed files with 295 additions and 63 deletions
@@ -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: () => <div data-testid='cached-image' />,
}));
// 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<unknown>,
) => (
<div>
{Array.from({ length: totalCount }, (_, index) => (
<div key={index}>{itemContent(index)}</div>
))}
</div>
),
),
};
});
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(
<FeedView
feed={feed}
baseURL='https://opds.example.com/opds'
resolveURL={(href, base) => 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);
});
});
@@ -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) => (
<section key={groupIndex} className='mb-12 flex-shrink-0'>
<section key={groupIndex} className={`flex-shrink-0 ${useCarousel ? 'mb-6' : 'mb-12'}`}>
{group.metadata && (
<div className='mb-4 flex items-center justify-between px-4'>
<h2 className='text-2xl font-bold'>{group.metadata.title}</h2>
<div
className={`flex items-center justify-between px-4 ${useCarousel ? 'mb-2' : 'mb-4'}`}
>
<h2 className={useCarousel ? 'text-lg font-bold' : 'text-2xl font-bold'}>
{group.metadata.title}
</h2>
{group.links && group.links.length > 0 && (
<button
onClick={() => {
@@ -191,34 +201,68 @@ export function FeedView({
</div>
)}
{group.navigation && (
<div className={`opds-navigation ${gridClassName}`}>
{group.navigation.map((item, itemIndex: number) => (
<NavigationCard
key={itemIndex}
item={item}
baseURL={baseURL}
onClick={handleNavigationClick}
resolveURL={resolveURL}
/>
))}
</div>
)}
{group.navigation &&
(useCarousel ? (
<GroupCarousel
count={group.navigation.length}
defaultRowHeight={80}
itemContent={(itemIndex) => (
<div data-carousel-item className='w-64 pr-4'>
<NavigationCard
item={group.navigation![itemIndex]!}
baseURL={baseURL}
onClick={handleNavigationClick}
resolveURL={resolveURL}
/>
</div>
)}
/>
) : (
<div className={`opds-navigation ${gridClassName}`}>
{group.navigation.map((item, itemIndex: number) => (
<NavigationCard
key={itemIndex}
item={item}
baseURL={baseURL}
onClick={handleNavigationClick}
resolveURL={resolveURL}
/>
))}
</div>
))}
{group.publications && (
<div className={`opds-publications ${gridClassName}`}>
{group.publications.map((pub, itemIndex: number) => (
<PublicationCard
key={itemIndex}
publication={pub}
baseURL={baseURL}
onClick={() => onPublicationSelect(groupIndex, itemIndex)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
))}
</div>
)}
{group.publications &&
(useCarousel ? (
<GroupCarousel
count={group.publications.length}
defaultRowHeight={250}
coverCentered
itemContent={(itemIndex) => (
<div data-carousel-item className='w-32 pr-4'>
<PublicationCard
publication={group.publications![itemIndex]!}
baseURL={baseURL}
onClick={() => onPublicationSelect(groupIndex, itemIndex)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
</div>
)}
/>
) : (
<div className={`opds-publications ${gridClassName}`}>
{group.publications.map((pub, itemIndex: number) => (
<PublicationCard
key={itemIndex}
publication={pub}
baseURL={baseURL}
onClick={() => onPublicationSelect(groupIndex, itemIndex)}
resolveURL={resolveURL}
onGenerateCachedImageUrl={onGenerateCachedImageUrl}
/>
))}
</div>
))}
</section>
))}
@@ -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<VirtuosoHandle>(null);
const scrollerRef = useRef<HTMLElement | null>(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<number | null>(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 (
<div className='relative' data-testid='group-carousel'>
<Virtuoso
ref={virtuosoRef}
horizontalDirection
totalCount={count}
itemContent={itemContent}
increaseViewportBy={200}
className='no-scrollbar px-4 pb-2'
style={{ height: rowHeight }}
scrollerRef={(ref) => {
scrollerRef.current = ref as HTMLElement;
}}
rangeChanged={(range) => {
rangeRef.current = range;
}}
atTopStateChange={(atStart) => setShowLeftArrow(!atStart)}
atBottomStateChange={(atEnd) => setShowRightArrow(!atEnd)}
totalListHeightChanged={measure}
/>
{showLeftArrow && (
<button
aria-label={_('Scroll left')}
onClick={() => scrollByPage(-1)}
style={{ top: coverCenter ?? '50%' }}
className='eink-bordered bg-base-100 border-base-content/10 hover:border-base-content/30 absolute left-2 -translate-y-1/2 rounded-full border p-1 shadow-sm transition-colors duration-200'
>
<MdChevronLeft size={20} className='text-base-content/60 hover:text-base-content/80' />
</button>
)}
{showRightArrow && (
<button
aria-label={_('Scroll right')}
onClick={() => scrollByPage(1)}
style={{ top: coverCenter ?? '50%' }}
className='eink-bordered bg-base-100 border-base-content/10 hover:border-base-content/30 absolute right-2 -translate-y-1/2 rounded-full border p-1 shadow-sm transition-colors duration-200'
>
<MdChevronRight size={20} className='text-base-content/60 hover:text-base-content/80' />
</button>
)}
</div>
);
}
@@ -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 (
<div role='none' onClick={onClick} className='card cursor-pointer transition-shadow'>
<figure className='bg-base-200 relative aspect-[28/41] rounded-none shadow-md'>
<figure className='bg-base-200 relative aspect-[28/41] overflow-hidden rounded shadow-md'>
<CachedImage
src={imageUrl}
alt={publication.metadata?.title || 'Book cover'}
@@ -91,11 +64,6 @@ export function PublicationCard({
{authors && authors.length > 0 && (
<p className='text-base-content/70 line-clamp-1 text-xs'>{authors.join(', ')}</p>
)}
{price && (
<div className='card-actions mt-2 justify-end'>
<div className='badge badge-outline badge-sm'>{price}</div>
</div>
)}
</div>
</div>
);
+9
View File
@@ -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;