mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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 { 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 { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
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
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isNewRelease: true,
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
newReleaseRank: 'asc',
|
||||
},
|
||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: {
|
||||
isNewRelease: true,
|
||||
},
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||
|
||||
// Validate pagination parameters
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
@@ -38,12 +39,22 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
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
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isPopular: true,
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
@@ -66,9 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: {
|
||||
isPopular: true,
|
||||
},
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
+36
-31
@@ -5,12 +5,12 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
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 { StickyPagination } from '@/components/ui/StickyPagination';
|
||||
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
@@ -29,24 +29,20 @@ export default function HomePage() {
|
||||
isLoading: loadingPopular,
|
||||
totalPages: popularTotalPages,
|
||||
message: popularMessage,
|
||||
} = useAudiobooks('popular', 20, popularPage);
|
||||
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
|
||||
|
||||
const {
|
||||
audiobooks: newReleases,
|
||||
isLoading: loadingNewReleases,
|
||||
totalPages: newReleasesTotalPages,
|
||||
message: newReleasesMessage,
|
||||
} = useAudiobooks('new-releases', 20, newReleasesPage);
|
||||
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
|
||||
|
||||
// Filter out available titles when hideAvailable is enabled
|
||||
const filteredPopular = useMemo(
|
||||
() => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular,
|
||||
[popular, hideAvailable]
|
||||
);
|
||||
const filteredNewReleases = useMemo(
|
||||
() => hideAvailable ? newReleases.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : newReleases,
|
||||
[newReleases, hideAvailable]
|
||||
);
|
||||
// Reset to page 1 when hideAvailable changes (total pages may differ)
|
||||
useEffect(() => {
|
||||
setPopularPage(1);
|
||||
setNewReleasesPage(1);
|
||||
}, [hideAvailable]);
|
||||
|
||||
// Handle page changes with auto-scroll to section top
|
||||
const handlePopularPageChange = (page: number) => {
|
||||
@@ -100,7 +96,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={filteredPopular}
|
||||
audiobooks={popular}
|
||||
isLoading={loadingPopular}
|
||||
emptyMessage="No popular audiobooks available"
|
||||
cardSize={cardSize}
|
||||
@@ -145,7 +141,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={filteredNewReleases}
|
||||
audiobooks={newReleases}
|
||||
isLoading={loadingNewReleases}
|
||||
emptyMessage="No new releases available"
|
||||
cardSize={cardSize}
|
||||
@@ -181,22 +177,31 @@ export default function HomePage() {
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Sticky Pagination Controls */}
|
||||
<StickyPagination
|
||||
currentPage={popularPage}
|
||||
totalPages={popularTotalPages}
|
||||
onPageChange={handlePopularPageChange}
|
||||
sectionRef={popularSectionRef}
|
||||
{/* Unified Pagination — single context-aware pill for both sections */}
|
||||
<UnifiedPagination
|
||||
footerRef={footerRef}
|
||||
label="Popular Audiobooks"
|
||||
/>
|
||||
<StickyPagination
|
||||
currentPage={newReleasesPage}
|
||||
totalPages={newReleasesTotalPages}
|
||||
onPageChange={handleNewReleasesPageChange}
|
||||
sectionRef={newReleasesSectionRef}
|
||||
footerRef={footerRef}
|
||||
label="New Releases"
|
||||
sections={[
|
||||
{
|
||||
label: 'Popular Audiobooks',
|
||||
accentColor: 'bg-blue-500',
|
||||
currentPage: popularPage,
|
||||
totalPages: popularTotalPages,
|
||||
onPageChange: handlePopularPageChange,
|
||||
sectionRef: popularSectionRef,
|
||||
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>
|
||||
</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
|
||||
}
|
||||
|
||||
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 =
|
||||
type === 'popular'
|
||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}`
|
||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}`;
|
||||
? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}`
|
||||
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
|
||||
@@ -272,6 +272,44 @@ export async function enrichAudiobooksWithMatches(
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({
|
||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/StickyPagination', () => ({
|
||||
StickyPagination: ({
|
||||
label,
|
||||
onPageChange,
|
||||
vi.mock('@/components/ui/UnifiedPagination', () => ({
|
||||
UnifiedPagination: ({
|
||||
sections,
|
||||
}: {
|
||||
label: string;
|
||||
onPageChange: (page: number) => void;
|
||||
sections: Array<{
|
||||
label: string;
|
||||
onPageChange: (page: number) => void;
|
||||
}>;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onPageChange(2)}>
|
||||
{label} next
|
||||
</button>
|
||||
<div>
|
||||
{sections.map((s) => (
|
||||
<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' }));
|
||||
|
||||
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