mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add hideAvailable filter and unified pagination
Add support for hiding audiobooks that are already available by introducing a hideAvailable query flag and excluding matching ASINs at the DB level. Implemented getAvailableAsins() in audiobook-matcher to gather ASINs from the library and completed requests, and wired it into the popular and new-releases API routes to apply a notIn filter. Propagated the hideAvailable flag through useAudiobooks so client requests include the parameter, and adjusted the homepage to reset pagination when the flag changes. Replaced two StickyPagination instances with a new UnifiedPagination component (new file) that provides a single context-aware floating paginator which tracks the dominant section and allows switching between Popular and New Releases. Also removed client-side filtering in favor of server-side exclusion and made small imports/cleanup in page.tsx.
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||||
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||||
|
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters
|
||||||
if (page < 1 || limit < 1 || limit > 100) {
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||||
|
let excludedAsins: string[] = [];
|
||||||
|
if (hideAvailable) {
|
||||||
|
const availableSet = await getAvailableAsins();
|
||||||
|
excludedAsins = [...availableSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
isNewRelease: true,
|
||||||
|
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
// Query audible_cache for new release audiobooks
|
// Query audible_cache for new release audiobooks
|
||||||
const [audiobooks, totalCount] = await Promise.all([
|
const [audiobooks, totalCount] = await Promise.all([
|
||||||
prisma.audibleCache.findMany({
|
prisma.audibleCache.findMany({
|
||||||
where: {
|
where: whereClause,
|
||||||
isNewRelease: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
newReleaseRank: 'asc',
|
newReleaseRank: 'asc',
|
||||||
},
|
},
|
||||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.audibleCache.count({
|
prisma.audibleCache.count({
|
||||||
where: {
|
where: whereClause,
|
||||||
isNewRelease: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||||
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||||
|
|
||||||
// Validate pagination parameters
|
// Validate pagination parameters
|
||||||
if (page < 1 || limit < 1 || limit > 100) {
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests
|
||||||
|
let excludedAsins: string[] = [];
|
||||||
|
if (hideAvailable) {
|
||||||
|
const availableSet = await getAvailableAsins();
|
||||||
|
excludedAsins = [...availableSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
isPopular: true,
|
||||||
|
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
// Query audible_cache for popular audiobooks
|
// Query audible_cache for popular audiobooks
|
||||||
const [audiobooks, totalCount] = await Promise.all([
|
const [audiobooks, totalCount] = await Promise.all([
|
||||||
prisma.audibleCache.findMany({
|
prisma.audibleCache.findMany({
|
||||||
where: {
|
where: whereClause,
|
||||||
isPopular: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
popularRank: 'asc',
|
popularRank: 'asc',
|
||||||
},
|
},
|
||||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.audibleCache.count({
|
prisma.audibleCache.count({
|
||||||
where: {
|
where: whereClause,
|
||||||
isPopular: true,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+36
-31
@@ -5,12 +5,12 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useMemo } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Header } from '@/components/layout/Header';
|
import { Header } from '@/components/layout/Header';
|
||||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||||
import { useAudiobooks, Audiobook } from '@/lib/hooks/useAudiobooks';
|
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
|
||||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
|
|
||||||
@@ -29,24 +29,20 @@ export default function HomePage() {
|
|||||||
isLoading: loadingPopular,
|
isLoading: loadingPopular,
|
||||||
totalPages: popularTotalPages,
|
totalPages: popularTotalPages,
|
||||||
message: popularMessage,
|
message: popularMessage,
|
||||||
} = useAudiobooks('popular', 20, popularPage);
|
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
audiobooks: newReleases,
|
audiobooks: newReleases,
|
||||||
isLoading: loadingNewReleases,
|
isLoading: loadingNewReleases,
|
||||||
totalPages: newReleasesTotalPages,
|
totalPages: newReleasesTotalPages,
|
||||||
message: newReleasesMessage,
|
message: newReleasesMessage,
|
||||||
} = useAudiobooks('new-releases', 20, newReleasesPage);
|
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
|
||||||
|
|
||||||
// Filter out available titles when hideAvailable is enabled
|
// Reset to page 1 when hideAvailable changes (total pages may differ)
|
||||||
const filteredPopular = useMemo(
|
useEffect(() => {
|
||||||
() => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular,
|
setPopularPage(1);
|
||||||
[popular, hideAvailable]
|
setNewReleasesPage(1);
|
||||||
);
|
}, [hideAvailable]);
|
||||||
const filteredNewReleases = useMemo(
|
|
||||||
() => hideAvailable ? newReleases.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : newReleases,
|
|
||||||
[newReleases, hideAvailable]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle page changes with auto-scroll to section top
|
// Handle page changes with auto-scroll to section top
|
||||||
const handlePopularPageChange = (page: number) => {
|
const handlePopularPageChange = (page: number) => {
|
||||||
@@ -100,7 +96,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AudiobookGrid
|
<AudiobookGrid
|
||||||
audiobooks={filteredPopular}
|
audiobooks={popular}
|
||||||
isLoading={loadingPopular}
|
isLoading={loadingPopular}
|
||||||
emptyMessage="No popular audiobooks available"
|
emptyMessage="No popular audiobooks available"
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
@@ -145,7 +141,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AudiobookGrid
|
<AudiobookGrid
|
||||||
audiobooks={filteredNewReleases}
|
audiobooks={newReleases}
|
||||||
isLoading={loadingNewReleases}
|
isLoading={loadingNewReleases}
|
||||||
emptyMessage="No new releases available"
|
emptyMessage="No new releases available"
|
||||||
cardSize={cardSize}
|
cardSize={cardSize}
|
||||||
@@ -181,22 +177,31 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Sticky Pagination Controls */}
|
{/* Unified Pagination — single context-aware pill for both sections */}
|
||||||
<StickyPagination
|
<UnifiedPagination
|
||||||
currentPage={popularPage}
|
|
||||||
totalPages={popularTotalPages}
|
|
||||||
onPageChange={handlePopularPageChange}
|
|
||||||
sectionRef={popularSectionRef}
|
|
||||||
footerRef={footerRef}
|
footerRef={footerRef}
|
||||||
label="Popular Audiobooks"
|
sections={[
|
||||||
/>
|
{
|
||||||
<StickyPagination
|
label: 'Popular Audiobooks',
|
||||||
currentPage={newReleasesPage}
|
accentColor: 'bg-blue-500',
|
||||||
totalPages={newReleasesTotalPages}
|
currentPage: popularPage,
|
||||||
onPageChange={handleNewReleasesPageChange}
|
totalPages: popularTotalPages,
|
||||||
sectionRef={newReleasesSectionRef}
|
onPageChange: handlePopularPageChange,
|
||||||
footerRef={footerRef}
|
sectionRef: popularSectionRef,
|
||||||
label="New Releases"
|
onScrollToSection: () =>
|
||||||
|
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New Releases',
|
||||||
|
accentColor: 'bg-emerald-500',
|
||||||
|
currentPage: newReleasesPage,
|
||||||
|
totalPages: newReleasesTotalPages,
|
||||||
|
onPageChange: handleNewReleasesPageChange,
|
||||||
|
sectionRef: newReleasesSectionRef,
|
||||||
|
onScrollToSection: () =>
|
||||||
|
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* Component: Sticky Pagination with Progress Bar
|
|
||||||
* Documentation: documentation/frontend/components.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
interface StickyPaginationProps {
|
|
||||||
currentPage: number;
|
|
||||||
totalPages: number;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
sectionRef: React.RefObject<HTMLElement | null>;
|
|
||||||
label: string; // e.g., "Popular Audiobooks"
|
|
||||||
footerRef?: React.RefObject<HTMLElement | null>; // Optional footer ref to avoid overlap
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StickyPagination({
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
onPageChange,
|
|
||||||
sectionRef,
|
|
||||||
label,
|
|
||||||
footerRef,
|
|
||||||
}: StickyPaginationProps) {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const [isFooterVisible, setIsFooterVisible] = useState(false);
|
|
||||||
const [jumpPage, setJumpPage] = useState(currentPage.toString());
|
|
||||||
|
|
||||||
// Update jump page input when current page changes externally
|
|
||||||
useEffect(() => {
|
|
||||||
setJumpPage(currentPage.toString());
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// Intersection Observer to show/hide pagination based on section visibility
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sectionRef.current) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
// Show pagination when section is in viewport
|
|
||||||
setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threshold: [0, 0.1, 0.5, 1],
|
|
||||||
rootMargin: '-60px 0px -60px 0px', // Account for header/footer
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sectionRef.current);
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [sectionRef]);
|
|
||||||
|
|
||||||
// Footer observer to hide pagination when footer is visible
|
|
||||||
useEffect(() => {
|
|
||||||
if (!footerRef?.current) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
// Hide pagination when footer is in viewport
|
|
||||||
setIsFooterVisible(entry.isIntersecting);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threshold: [0, 0.1],
|
|
||||||
rootMargin: '0px',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(footerRef.current);
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [footerRef]);
|
|
||||||
|
|
||||||
if (totalPages <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePrevious = () => {
|
|
||||||
if (currentPage > 1) {
|
|
||||||
onPageChange(currentPage - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
if (currentPage < totalPages) {
|
|
||||||
onPageChange(currentPage + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJumpSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const page = parseInt(jumpPage, 10);
|
|
||||||
if (!isNaN(page) && page >= 1 && page <= totalPages) {
|
|
||||||
onPageChange(page);
|
|
||||||
} else {
|
|
||||||
// Reset to current page if invalid
|
|
||||||
setJumpPage(currentPage.toString());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Final visibility: show when section is visible AND footer is not visible
|
|
||||||
const shouldShow = isVisible && !isFooterVisible;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
|
|
||||||
shouldShow ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Section Label - Hidden on small screens */}
|
|
||||||
<div className="hidden md:block text-xs font-medium text-gray-600 dark:text-gray-400 pr-2 border-r border-gray-300 dark:border-gray-600">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Previous Button */}
|
|
||||||
<button
|
|
||||||
onClick={handlePrevious}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
|
||||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
|
||||||
transition-colors"
|
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Page Info & Jump */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
|
||||||
Page
|
|
||||||
</span>
|
|
||||||
<form onSubmit={handleJumpSubmit} className="inline-flex">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={jumpPage}
|
|
||||||
onChange={(e) => setJumpPage(e.target.value)}
|
|
||||||
onBlur={handleJumpSubmit}
|
|
||||||
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded
|
|
||||||
bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
|
||||||
border border-gray-300 dark:border-gray-600
|
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
aria-label="Current page"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
|
||||||
of {totalPages}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Next Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
|
||||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
|
||||||
transition-colors"
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
/**
|
||||||
|
* Component: Unified Pagination — context-aware floating paginator
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*
|
||||||
|
* Replaces two overlapping StickyPagination instances with a single pill
|
||||||
|
* that automatically tracks which section dominates the viewport and shows
|
||||||
|
* controls for that section. Transitions smoothly when the dominant section
|
||||||
|
* changes. Includes a two-dot section indicator for manual switching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export interface PaginationSection {
|
||||||
|
/** Display label, e.g. "Popular Audiobooks" */
|
||||||
|
label: string;
|
||||||
|
/** Tailwind color class applied to the active accent dot, e.g. "bg-blue-500" */
|
||||||
|
accentColor: string;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
/** Ref to the section element — used for intersection tracking */
|
||||||
|
sectionRef: React.RefObject<HTMLElement | null>;
|
||||||
|
/** Called when user clicks this section's dot while it's inactive — should scroll to section */
|
||||||
|
onScrollToSection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedPaginationProps {
|
||||||
|
sections: [PaginationSection, PaginationSection];
|
||||||
|
footerRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small page-jump form — isolated to prevent key re-mounts on section switch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PageJumpProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) {
|
||||||
|
const [value, setValue] = useState(currentPage.toString());
|
||||||
|
|
||||||
|
// Sync when page changes externally (e.g. after scrollIntoView + setState)
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(currentPage.toString());
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (!isNaN(parsed) && parsed >= 1 && parsed <= totalPages) {
|
||||||
|
onPageChange(parsed);
|
||||||
|
} else {
|
||||||
|
setValue(currentPage.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[value, currentPage, totalPages, onPageChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||||
|
Page
|
||||||
|
</span>
|
||||||
|
<form onSubmit={commit} className="inline-flex">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded-md
|
||||||
|
bg-black/[0.04] dark:bg-white/[0.08]
|
||||||
|
text-gray-900 dark:text-gray-100
|
||||||
|
border border-gray-300/60 dark:border-white/10
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent
|
||||||
|
transition-all duration-150"
|
||||||
|
aria-label="Jump to page"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||||
|
of {totalPages}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
|
||||||
|
// Index of the currently dominant section (0 or 1)
|
||||||
|
const [activeIndex, setActiveIndex] = useState<0 | 1>(0);
|
||||||
|
// Whether the label+controls area is mid-transition (drives opacity fade)
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
|
|
||||||
|
const [footerVisible, setFooterVisible] = useState(false);
|
||||||
|
// Per-section raw intersection ratios [0,1]
|
||||||
|
const ratiosRef = useRef<[number, number]>([0, 0]);
|
||||||
|
// Whether each section has any meaningful intersection
|
||||||
|
const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]);
|
||||||
|
|
||||||
|
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Determine if the pill should be shown at all:
|
||||||
|
// - at least one section is meaningfully visible
|
||||||
|
// - footer is not visible
|
||||||
|
// - the active section has >1 page
|
||||||
|
const activeSectionHasPages = sections[activeIndex].totalPages > 1;
|
||||||
|
const eitherSectionVisible = sectionVisible[0] || sectionVisible[1];
|
||||||
|
const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Track which section each instance belongs to via intersection ratio
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
const observers: IntersectionObserver[] = [];
|
||||||
|
|
||||||
|
sections.forEach((section, idx) => {
|
||||||
|
if (!section.sectionRef.current) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio;
|
||||||
|
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05;
|
||||||
|
|
||||||
|
setSectionVisible((prev) => {
|
||||||
|
const next: [boolean, boolean] = [...prev] as [boolean, boolean];
|
||||||
|
next[idx as 0 | 1] = isVisible;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine dominant section (whichever has more viewport coverage)
|
||||||
|
const [r0, r1] = ratiosRef.current;
|
||||||
|
const dominant: 0 | 1 = r0 >= r1 ? 0 : 1;
|
||||||
|
|
||||||
|
setActiveIndex((current) => {
|
||||||
|
if (current !== dominant) {
|
||||||
|
// Trigger cross-fade transition
|
||||||
|
setIsTransitioning(true);
|
||||||
|
|
||||||
|
if (transitionTimerRef.current) {
|
||||||
|
clearTimeout(transitionTimerRef.current);
|
||||||
|
}
|
||||||
|
transitionTimerRef.current = setTimeout(() => {
|
||||||
|
setIsTransitioning(false);
|
||||||
|
}, 320);
|
||||||
|
|
||||||
|
return dominant;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Dense threshold array gives us smooth ratio tracking
|
||||||
|
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
|
||||||
|
rootMargin: '-60px 0px -80px 0px',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(section.sectionRef.current);
|
||||||
|
observers.push(observer);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observers.forEach((o) => o.disconnect());
|
||||||
|
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sections[0].sectionRef, sections[1].sectionRef]);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Footer observer
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
if (!footerRef?.current) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => setFooterVisible(entry.isIntersecting),
|
||||||
|
{ threshold: [0, 0.01] }
|
||||||
|
);
|
||||||
|
observer.observe(footerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [footerRef]);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Derived values for the currently active section
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const active = sections[activeIndex];
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (active.currentPage > 1) active.onPageChange(active.currentPage - 1);
|
||||||
|
};
|
||||||
|
const handleNext = () => {
|
||||||
|
if (active.currentPage < active.totalPages) active.onPageChange(active.currentPage + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
fixed bottom-6 left-1/2 -translate-x-1/2 z-40
|
||||||
|
transition-all duration-300 ease-out
|
||||||
|
${shouldShow
|
||||||
|
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||||
|
: 'translate-y-4 opacity-0 pointer-events-none'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
aria-hidden={!shouldShow}
|
||||||
|
>
|
||||||
|
{/* Pill surface */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
flex items-center gap-0
|
||||||
|
bg-white/90 dark:bg-gray-900/90
|
||||||
|
backdrop-blur-xl
|
||||||
|
rounded-full
|
||||||
|
shadow-[0_8px_32px_rgba(0,0,0,0.12),0_2px_8px_rgba(0,0,0,0.08)]
|
||||||
|
dark:shadow-[0_8px_32px_rgba(0,0,0,0.4),0_2px_8px_rgba(0,0,0,0.3)]
|
||||||
|
border border-gray-200/60 dark:border-white/[0.08]
|
||||||
|
px-1.5 py-1.5
|
||||||
|
overflow-hidden
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Section selector dots — left side */}
|
||||||
|
<div className="flex flex-col gap-1 pl-2 pr-3">
|
||||||
|
{sections.map((section, idx) => {
|
||||||
|
const isActive = idx === activeIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.label}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isActive) section.onScrollToSection();
|
||||||
|
}}
|
||||||
|
disabled={isActive}
|
||||||
|
title={section.label}
|
||||||
|
aria-label={`Switch to ${section.label}`}
|
||||||
|
className={`
|
||||||
|
w-1.5 rounded-full transition-all duration-300 ease-out
|
||||||
|
${isActive
|
||||||
|
? `${section.accentColor} h-4 opacity-100`
|
||||||
|
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
|
||||||
|
|
||||||
|
{/* Label + controls — cross-fades on section switch */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3
|
||||||
|
transition-opacity duration-200 ease-in-out
|
||||||
|
${isTransitioning ? 'opacity-0' : 'opacity-100'}
|
||||||
|
`}
|
||||||
|
// key forces full remount on switch so input state resets cleanly
|
||||||
|
key={activeIndex}
|
||||||
|
>
|
||||||
|
{/* Section label — hidden on small screens */}
|
||||||
|
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none">
|
||||||
|
{active.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Previous */}
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={active.currentPage === 1}
|
||||||
|
aria-label="Previous page"
|
||||||
|
className="
|
||||||
|
p-1.5 rounded-full
|
||||||
|
text-gray-600 dark:text-gray-300
|
||||||
|
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||||
|
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||||
|
active:scale-95
|
||||||
|
disabled:opacity-25 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="w-4 h-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page jump */}
|
||||||
|
<PageJump
|
||||||
|
currentPage={active.currentPage}
|
||||||
|
totalPages={active.totalPages}
|
||||||
|
onPageChange={active.onPageChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={active.currentPage === active.totalPages}
|
||||||
|
aria-label="Next page"
|
||||||
|
className="
|
||||||
|
p-1.5 rounded-full
|
||||||
|
text-gray-600 dark:text-gray-300
|
||||||
|
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||||
|
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||||
|
active:scale-95
|
||||||
|
disabled:opacity-25 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-150
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="w-4 h-4" strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right padding balance */}
|
||||||
|
<div className="w-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,11 +35,12 @@ export interface Audiobook {
|
|||||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
|
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||||
|
const hideParam = hideAvailable ? '&hideAvailable=true' : '';
|
||||||
const endpoint =
|
const endpoint =
|
||||||
type === 'popular'
|
type === 'popular'
|
||||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}`
|
? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}`
|
||||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}`;
|
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`;
|
||||||
|
|
||||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
|
|||||||
@@ -272,6 +272,44 @@ export async function enrichAudiobooksWithMatches(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all ASINs that are considered "available" — present in library or have completed requests.
|
||||||
|
* Used by paginated API routes to exclude available items at the DB level.
|
||||||
|
*/
|
||||||
|
export async function getAvailableAsins(): Promise<Set<string>> {
|
||||||
|
const [libraryItems, completedRequests] = await Promise.all([
|
||||||
|
// ASINs present in the library (Plex or Audiobookshelf)
|
||||||
|
prisma.plexLibrary.findMany({
|
||||||
|
where: { asin: { not: null } },
|
||||||
|
select: { asin: true },
|
||||||
|
distinct: ['asin'],
|
||||||
|
}),
|
||||||
|
// ASINs with completed audiobook requests
|
||||||
|
prisma.audiobook.findMany({
|
||||||
|
where: {
|
||||||
|
audibleAsin: { not: null },
|
||||||
|
requests: {
|
||||||
|
some: {
|
||||||
|
status: 'completed',
|
||||||
|
type: 'audiobook',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { audibleAsin: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const asins = new Set<string>();
|
||||||
|
for (const item of libraryItems) {
|
||||||
|
if (item.asin) asins.add(item.asin);
|
||||||
|
}
|
||||||
|
for (const item of completedRequests) {
|
||||||
|
if (item.audibleAsin) asins.add(item.audibleAsin);
|
||||||
|
}
|
||||||
|
return asins;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize ISBN for comparison (remove dashes and spaces)
|
* Normalize ISBN for comparison (remove dashes and spaces)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({
|
|||||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/ui/StickyPagination', () => ({
|
vi.mock('@/components/ui/UnifiedPagination', () => ({
|
||||||
StickyPagination: ({
|
UnifiedPagination: ({
|
||||||
label,
|
sections,
|
||||||
onPageChange,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
sections: Array<{
|
||||||
onPageChange: (page: number) => void;
|
label: string;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}>;
|
||||||
}) => (
|
}) => (
|
||||||
<button type="button" onClick={() => onPageChange(2)}>
|
<div>
|
||||||
{label} next
|
{sections.map((s) => (
|
||||||
</button>
|
<button key={s.label} type="button" onClick={() => s.onPageChange(2)}>
|
||||||
|
{s.label} next
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ describe('HomePage', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2);
|
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* Component: Sticky Pagination Tests
|
|
||||||
* Documentation: documentation/frontend/components.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
|
||||||
|
|
||||||
type ObserverEntry = {
|
|
||||||
isIntersecting: boolean;
|
|
||||||
intersectionRatio: number;
|
|
||||||
target: Element;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('StickyPagination', () => {
|
|
||||||
const observers: { callback: IntersectionObserverCallback }[] = [];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
observers.length = 0;
|
|
||||||
class MockIntersectionObserver {
|
|
||||||
callback: IntersectionObserverCallback;
|
|
||||||
observe = vi.fn();
|
|
||||||
unobserve = vi.fn();
|
|
||||||
disconnect = vi.fn();
|
|
||||||
takeRecords = vi.fn();
|
|
||||||
|
|
||||||
constructor(callback: IntersectionObserverCallback) {
|
|
||||||
this.callback = callback;
|
|
||||||
observers.push(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(global as any).IntersectionObserver = MockIntersectionObserver;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when there is only one page', () => {
|
|
||||||
const sectionRef = { current: document.createElement('div') };
|
|
||||||
const { container } = render(
|
|
||||||
<StickyPagination
|
|
||||||
currentPage={1}
|
|
||||||
totalPages={1}
|
|
||||||
onPageChange={vi.fn()}
|
|
||||||
sectionRef={sectionRef}
|
|
||||||
label="Popular"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.firstChild).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows and hides based on section and footer visibility', () => {
|
|
||||||
const sectionRef = { current: document.createElement('div') };
|
|
||||||
const footerRef = { current: document.createElement('div') };
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<StickyPagination
|
|
||||||
currentPage={2}
|
|
||||||
totalPages={5}
|
|
||||||
onPageChange={vi.fn()}
|
|
||||||
sectionRef={sectionRef}
|
|
||||||
footerRef={footerRef}
|
|
||||||
label="Popular"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
|
||||||
expect(root).toHaveClass('opacity-0');
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
observers[0].callback(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
isIntersecting: true,
|
|
||||||
intersectionRatio: 0.2,
|
|
||||||
target: sectionRef.current as Element,
|
|
||||||
} as ObserverEntry,
|
|
||||||
],
|
|
||||||
observers[0] as unknown as IntersectionObserver
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(root).toHaveClass('opacity-100');
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
observers[1].callback(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
isIntersecting: true,
|
|
||||||
intersectionRatio: 0.2,
|
|
||||||
target: footerRef.current as Element,
|
|
||||||
} as ObserverEntry,
|
|
||||||
],
|
|
||||||
observers[1] as unknown as IntersectionObserver
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(root).toHaveClass('opacity-0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles navigation and jump input updates', () => {
|
|
||||||
const sectionRef = { current: document.createElement('div') };
|
|
||||||
const onPageChange = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<StickyPagination
|
|
||||||
currentPage={2}
|
|
||||||
totalPages={4}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
sectionRef={sectionRef}
|
|
||||||
label="Popular"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('Next page'));
|
|
||||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
|
||||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
|
||||||
|
|
||||||
const input = screen.getByLabelText('Current page') as HTMLInputElement;
|
|
||||||
fireEvent.change(input, { target: { value: '4' } });
|
|
||||||
fireEvent.blur(input);
|
|
||||||
expect(onPageChange).toHaveBeenCalledWith(4);
|
|
||||||
|
|
||||||
fireEvent.change(input, { target: { value: '99' } });
|
|
||||||
fireEvent.blur(input);
|
|
||||||
expect(input.value).toBe('2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Component: Unified Pagination Tests
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
|
||||||
|
|
||||||
|
type ObserverEntry = {
|
||||||
|
isIntersecting: boolean;
|
||||||
|
intersectionRatio: number;
|
||||||
|
target: Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeSections(
|
||||||
|
overrides?: Partial<PaginationSection>[]
|
||||||
|
): [PaginationSection, PaginationSection] {
|
||||||
|
const defaults: [PaginationSection, PaginationSection] = [
|
||||||
|
{
|
||||||
|
label: 'Popular',
|
||||||
|
accentColor: 'bg-blue-500',
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 3,
|
||||||
|
onPageChange: vi.fn(),
|
||||||
|
sectionRef: { current: document.createElement('section') },
|
||||||
|
onScrollToSection: vi.fn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New Releases',
|
||||||
|
accentColor: 'bg-emerald-500',
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 2,
|
||||||
|
onPageChange: vi.fn(),
|
||||||
|
sectionRef: { current: document.createElement('section') },
|
||||||
|
onScrollToSection: vi.fn(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (overrides) {
|
||||||
|
overrides.forEach((o, i) => {
|
||||||
|
if (o) Object.assign(defaults[i], o);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UnifiedPagination', () => {
|
||||||
|
const observers: { callback: IntersectionObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
observers.length = 0;
|
||||||
|
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
callback: IntersectionObserverCallback;
|
||||||
|
observe = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
takeRecords = vi.fn();
|
||||||
|
|
||||||
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
observers.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(global as any).IntersectionObserver = MockIntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when both sections have only one page', () => {
|
||||||
|
const sections = makeSections([{ totalPages: 1 }, { totalPages: 1 }]);
|
||||||
|
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||||
|
// The pill should be hidden (pointer-events-none, opacity-0)
|
||||||
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
|
expect(root).toHaveClass('pointer-events-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows pagination when the dominant section is visible and has pages', () => {
|
||||||
|
const sections = makeSections();
|
||||||
|
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||||
|
|
||||||
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
|
expect(root).toHaveClass('opacity-0');
|
||||||
|
|
||||||
|
// Simulate first section becoming visible with high ratio
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.5,
|
||||||
|
target: sections[0].sectionRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[0] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root).toHaveClass('opacity-100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides when footer becomes visible', () => {
|
||||||
|
const sections = makeSections();
|
||||||
|
const footerRef = { current: document.createElement('footer') };
|
||||||
|
const { container } = render(
|
||||||
|
<UnifiedPagination sections={sections} footerRef={footerRef} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
|
|
||||||
|
// Make section visible
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.5,
|
||||||
|
target: sections[0].sectionRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[0] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root).toHaveClass('opacity-100');
|
||||||
|
|
||||||
|
// Footer observer is the 3rd (index 2): section0, section1, footer
|
||||||
|
act(() => {
|
||||||
|
observers[2].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.1,
|
||||||
|
target: footerRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[2] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root).toHaveClass('opacity-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onPageChange for prev/next buttons', () => {
|
||||||
|
const sections = makeSections([{ currentPage: 2, totalPages: 4 }]);
|
||||||
|
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||||
|
|
||||||
|
// Make section visible so controls render interactably
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.5,
|
||||||
|
target: sections[0].sectionRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[0] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Next page'));
|
||||||
|
expect(sections[0].onPageChange).toHaveBeenCalledWith(3);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||||
|
expect(sections[0].onPageChange).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles page jump input', () => {
|
||||||
|
const sections = makeSections([{ currentPage: 2, totalPages: 5 }]);
|
||||||
|
render(<UnifiedPagination sections={sections} />);
|
||||||
|
|
||||||
|
// Make section visible
|
||||||
|
act(() => {
|
||||||
|
observers[0].callback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
isIntersecting: true,
|
||||||
|
intersectionRatio: 0.5,
|
||||||
|
target: sections[0].sectionRef.current as Element,
|
||||||
|
} as ObserverEntry,
|
||||||
|
],
|
||||||
|
observers[0] as unknown as IntersectionObserver
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Jump to page') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: '4' } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
expect(sections[0].onPageChange).toHaveBeenCalledWith(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses pointer-events-none when hidden', () => {
|
||||||
|
const sections = makeSections();
|
||||||
|
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||||
|
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||||
|
expect(root).toHaveClass('pointer-events-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user