Add per-user home sections & unified Audible cache

Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor.
This commit is contained in:
kikootwo
2026-03-05 11:30:39 -05:00
parent 248bd5359c
commit cc8e106a2b
40 changed files with 2582 additions and 655 deletions
+151 -170
View File
@@ -1,208 +1,189 @@
/**
* Component: Homepage - Audiobook Discovery
* Documentation: documentation/frontend/components.md
* Component: Homepage - Audiobook Discovery (Dynamic Sections)
* Documentation: documentation/features/home-sections.md
*/
'use client';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback, createRef } from 'react';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { UnifiedPagination } from '@/components/ui/UnifiedPagination';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
import { HomeSection, SECTION_DOT_COLORS } from '@/components/home/HomeSection';
import { HomeSectionConfigModal } from '@/components/home/HomeSectionConfigModal';
import { useHomeSections } from '@/lib/hooks/useHomeSections';
import { usePreferences } from '@/contexts/PreferencesContext';
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
if (sectionType === 'popular') return 'Popular Audiobooks';
if (sectionType === 'new_releases') return 'New Releases';
return categoryName || 'Category';
}
export default function HomePage() {
const [popularPage, setPopularPage] = useState(1);
const [newReleasesPage, setNewReleasesPage] = useState(1);
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
// Refs for auto-scrolling to section tops
const popularSectionRef = useRef<HTMLElement>(null);
const newReleasesSectionRef = useRef<HTMLElement>(null);
// Per-section pagination state
const [pages, setPages] = useState<Record<string, number>>({});
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
const [configOpen, setConfigOpen] = useState(false);
const footerRef = useRef<HTMLElement>(null);
const {
audiobooks: popular,
isLoading: loadingPopular,
totalPages: popularTotalPages,
message: popularMessage,
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
// Create stable refs for each section
const sectionRefsMap = useRef<Map<string, React.RefObject<HTMLElement | null>>>(new Map());
const {
audiobooks: newReleases,
isLoading: loadingNewReleases,
totalPages: newReleasesTotalPages,
message: newReleasesMessage,
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
const getSectionKey = (s: { sectionType: string; categoryId: string | null }) =>
s.sectionType === 'category' ? `category:${s.categoryId}` : s.sectionType;
// Reset to page 1 when hideAvailable changes (total pages may differ)
// Ensure refs exist for current sections
sections.forEach((s) => {
const key = getSectionKey(s);
if (!sectionRefsMap.current.has(key)) {
sectionRefsMap.current.set(key, createRef<HTMLElement>());
}
});
// Reset pages and totalPages when hideAvailable changes
useEffect(() => {
setPopularPage(1);
setNewReleasesPage(1);
setPages({});
setTotalPagesMap({});
}, [hideAvailable]);
// Handle page changes with auto-scroll to section top
const handlePopularPageChange = (page: number) => {
setPopularPage(page);
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const getPage = (key: string) => pages[key] || 1;
const setPage = useCallback((key: string, page: number) => {
setPages((prev) => ({ ...prev, [key]: page }));
}, []);
const handleTotalPagesChange = useCallback((key: string, totalPages: number) => {
setTotalPagesMap((prev) => {
if (prev[key] === totalPages) return prev;
return { ...prev, [key]: totalPages };
});
}, []);
const handleNewReleasesPageChange = (page: number) => {
setNewReleasesPage(page);
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
// Build pagination sections for the floating pill
const paginationSections: PaginationSection[] = sections.map((s, i) => {
const key = getSectionKey(s);
const ref = sectionRefsMap.current.get(key)!;
return {
label: getSectionTitle(s.sectionType, s.categoryName),
accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length],
currentPage: getPage(key),
totalPages: totalPagesMap[key] || 1,
onPageChange: (page: number) => {
setPage(key, page);
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
sectionRef: ref,
onScrollToSection: () =>
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
};
});
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
{/* Popular Audiobooks Section */}
<section ref={popularSectionRef} className="relative">
{/* Sticky Section Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
Popular Audiobooks
</h2>
<SectionToolbar
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
{/* Loading state */}
{sectionsLoading && (
<div className="flex justify-center py-20">
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
</div>
)}
{/* Empty state */}
{!sectionsLoading && sections.length === 0 && (
<div className="text-center py-20">
<p className="text-gray-500 dark:text-gray-400 mb-4">
No sections configured. Click Customize to add sections to your home page.
</p>
<button
onClick={() => setConfigOpen(true)}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<Cog6ToothIcon className="w-4 h-4 mr-2" />
Customize Home
</button>
</div>
)}
{/* Dynamic sections */}
{!sectionsLoading &&
sections.map((section, index) => {
const key = getSectionKey(section);
const ref = sectionRefsMap.current.get(key)!;
return (
<HomeSection
key={key}
sectionType={section.sectionType as 'popular' | 'new_releases' | 'category'}
categoryId={section.categoryId}
categoryName={section.categoryName}
colorIndex={index}
page={getPage(key)}
onPageChange={(page) => {
setPage(key, page);
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
sectionRef={ref}
cardSize={cardSize}
squareCovers={squareCovers}
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
onConfigOpen={index === 0 ? () => setConfigOpen(true) : undefined}
onTotalPagesChange={(tp) => handleTotalPagesChange(key, tp)}
nextRefresh={nextRefresh}
/>
</div>
);
})}
{/* Call to Action */}
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Can't find what you're looking for?
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Use our search to find any audiobook from Audible
</p>
<a
href="/search"
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
>
Search Audiobooks
</a>
</section>
</main>
{/* Footer */}
<footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
<p>ReadMeABook - Audiobook Library Management System</p>
</div>
</div>
</footer>
{/* Section Content */}
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
{popularMessage && !loadingPopular && popular.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
No popular audiobooks found
</p>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
{popularMessage}
</p>
</div>
) : (
<AudiobookGrid
audiobooks={popular}
isLoading={loadingPopular}
emptyMessage="No popular audiobooks available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
)}
</div>
</section>
{/* Unified Pagination — dynamic sections */}
{paginationSections.length > 0 && (
<UnifiedPagination
footerRef={footerRef}
sections={paginationSections}
/>
)}
{/* New Releases Section */}
<section ref={newReleasesSectionRef} className="relative">
{/* Sticky Section Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
New Releases
</h2>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
{/* Section Content */}
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
{newReleasesMessage && !loadingNewReleases && newReleases.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
No new releases found
</p>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
{newReleasesMessage}
</p>
</div>
) : (
<AudiobookGrid
audiobooks={newReleases}
isLoading={loadingNewReleases}
emptyMessage="No new releases available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
)}
</div>
</section>
{/* Call to Action */}
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Can't find what you're looking for?
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Use our search to find any audiobook from Audible
</p>
<a
href="/search"
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
>
Search Audiobooks
</a>
</section>
</main>
{/* Footer */}
<footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
<p>ReadMeABook - Audiobook Library Management System</p>
</div>
</div>
</footer>
{/* Unified Pagination — single context-aware pill for both sections */}
<UnifiedPagination
footerRef={footerRef}
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' }),
},
]}
/>
{/* Config Modal */}
<HomeSectionConfigModal
isOpen={configOpen}
onClose={() => setConfigOpen(false)}
sections={sections}
onSave={saveSections}
/>
</div>
</ProtectedRoute>
);