forked from akai/readest
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user