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
+310
View File
@@ -0,0 +1,310 @@
/**
* Component: Home Section — renders a single audiobook discovery section
* Documentation: documentation/features/home-sections.md
*
* Handles popular, new_releases, and category section types with unified rendering.
*/
'use client';
import React, { useEffect } from 'react';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
import { useCategoryAudiobooks } from '@/lib/hooks/useHomeSections';
import { Cog6ToothIcon, ClockIcon } from '@heroicons/react/24/outline';
const SECTION_COLORS = [
'from-blue-500 to-indigo-500',
'from-emerald-500 to-teal-500',
'from-violet-500 to-purple-500',
'from-amber-500 to-orange-500',
'from-rose-500 to-pink-500',
'from-cyan-500 to-sky-500',
'from-fuchsia-500 to-pink-500',
'from-lime-500 to-green-500',
'from-orange-500 to-red-500',
'from-teal-500 to-emerald-500',
];
export const SECTION_DOT_COLORS = [
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
];
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
if (sectionType === 'popular') return 'Popular Audiobooks';
if (sectionType === 'new_releases') return 'New Releases';
return categoryName || 'Category';
}
/**
* Formats a nextRefresh ISO timestamp into a friendly, readable string.
* Examples: "today at 6:00 PM", "tomorrow at 2:00 AM", "Saturday at 9:00 AM"
*/
function formatNextRefresh(isoString: string): string {
const refreshDate = new Date(isoString);
const now = new Date();
const refreshMidnight = new Date(refreshDate);
refreshMidnight.setHours(0, 0, 0, 0);
const todayMidnight = new Date(now);
todayMidnight.setHours(0, 0, 0, 0);
const tomorrowMidnight = new Date(todayMidnight);
tomorrowMidnight.setDate(tomorrowMidnight.getDate() + 1);
const dayAfterMidnight = new Date(tomorrowMidnight);
dayAfterMidnight.setDate(dayAfterMidnight.getDate() + 1);
const timeStr = refreshDate.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
if (refreshMidnight.getTime() === todayMidnight.getTime()) {
return `today at ${timeStr}`;
}
if (refreshMidnight.getTime() === tomorrowMidnight.getTime()) {
return `tomorrow at ${timeStr}`;
}
if (refreshMidnight.getTime() < dayAfterMidnight.getTime()) {
const dayName = refreshDate.toLocaleDateString(undefined, { weekday: 'long' });
return `${dayName} at ${timeStr}`;
}
const dateStr = refreshDate.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
});
return `${dateStr} at ${timeStr}`;
}
interface HomeSectionProps {
sectionType: 'popular' | 'new_releases' | 'category';
categoryId: string | null;
categoryName: string | null;
colorIndex: number;
page: number;
onPageChange: (page: number) => void;
sectionRef: React.RefObject<HTMLElement | null>;
cardSize: number;
squareCovers: boolean;
hideAvailable: boolean;
onToggleHideAvailable: (v: boolean) => void;
onToggleSquareCovers: (v: boolean) => void;
onCardSizeChange: (v: number) => void;
onConfigOpen?: () => void;
onTotalPagesChange?: (totalPages: number) => void;
nextRefresh: string | null;
}
function PopularOrNewSection({
type,
page,
hideAvailable,
onTotalPagesChange,
...renderProps
}: {
type: 'popular' | 'new-releases';
page: number;
hideAvailable: boolean;
onTotalPagesChange?: (totalPages: number) => void;
} & RenderSectionProps) {
const { audiobooks, isLoading, totalPages, message } = useAudiobooks(type, 20, page, hideAvailable);
useEffect(() => {
onTotalPagesChange?.(totalPages);
}, [totalPages, onTotalPagesChange]);
return (
<RenderSection
audiobooks={audiobooks}
isLoading={isLoading}
totalPages={totalPages}
message={message}
{...renderProps}
/>
);
}
function CategorySection({
categoryId,
page,
hideAvailable,
onTotalPagesChange,
...renderProps
}: {
categoryId: string;
page: number;
hideAvailable: boolean;
onTotalPagesChange?: (totalPages: number) => void;
} & RenderSectionProps) {
const { audiobooks, isLoading, totalPages, message } = useCategoryAudiobooks(
categoryId,
20,
page,
hideAvailable
);
useEffect(() => {
onTotalPagesChange?.(totalPages);
}, [totalPages, onTotalPagesChange]);
return (
<RenderSection
audiobooks={audiobooks}
isLoading={isLoading}
totalPages={totalPages}
message={message}
{...renderProps}
/>
);
}
interface RenderSectionProps {
cardSize: number;
squareCovers: boolean;
nextRefresh?: string | null;
}
function CategoryEmptyState({ nextRefresh }: { nextRefresh?: string | null }) {
const refreshLabel = nextRefresh ? formatNextRefresh(nextRefresh) : null;
return (
<div className="flex flex-col items-center justify-center py-14 px-6 text-center">
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-gray-100 dark:bg-gray-700/60 mb-4">
<ClockIcon className="w-5 h-5 text-gray-400 dark:text-gray-500" />
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
No audiobooks yet
</p>
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs leading-relaxed">
{refreshLabel
? <>This section will fill in after the next data refresh, scheduled for <span className="text-gray-500 dark:text-gray-400">{refreshLabel}</span>.</>
: 'This section will fill in after the next scheduled data refresh.'}
</p>
</div>
);
}
function RenderSection({
audiobooks,
isLoading,
totalPages,
message,
cardSize,
squareCovers,
nextRefresh,
}: RenderSectionProps & {
audiobooks: any[];
isLoading: boolean;
totalPages: number;
message: string | null;
}) {
if (message && !isLoading && audiobooks.length === 0) {
return <CategoryEmptyState nextRefresh={nextRefresh} />;
}
return (
<AudiobookGrid
audiobooks={audiobooks}
isLoading={isLoading}
emptyMessage="No audiobooks available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
);
}
export function HomeSection({
sectionType,
categoryId,
categoryName,
colorIndex,
page,
onPageChange,
sectionRef,
cardSize,
squareCovers,
hideAvailable,
onToggleHideAvailable,
onToggleSquareCovers,
onCardSizeChange,
onConfigOpen,
onTotalPagesChange,
nextRefresh,
}: HomeSectionProps) {
const gradient = SECTION_COLORS[colorIndex % SECTION_COLORS.length];
const title = getSectionTitle(sectionType, categoryName);
const renderProps: RenderSectionProps = { cardSize, squareCovers, nextRefresh };
return (
<section ref={sectionRef} 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 ${gradient} rounded-full`} />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
{title}
</h2>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={onToggleHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={onToggleSquareCovers}
cardSize={cardSize}
onCardSizeChange={onCardSizeChange}
/>
{onConfigOpen && (
<button
onClick={onConfigOpen}
className="p-1.5 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
aria-label="Customize home page"
title="Customize sections"
>
<Cog6ToothIcon className="w-4 h-4" />
</button>
)}
</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">
{sectionType === 'popular' && (
<PopularOrNewSection
type="popular"
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
{sectionType === 'new_releases' && (
<PopularOrNewSection
type="new-releases"
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
{sectionType === 'category' && categoryId && (
<CategorySection
categoryId={categoryId}
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
</div>
</section>
);
}
@@ -0,0 +1,342 @@
/**
* Component: Home Section Configuration Modal
* Documentation: documentation/features/home-sections.md
*
* Allows users to add/remove/reorder home page sections.
* Drag-and-drop on desktop, up/down arrows on mobile. Auto-save with debounce.
*/
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
XMarkIcon,
PlusIcon,
TrashIcon,
ChevronUpIcon,
ChevronDownIcon,
Bars3Icon,
} from '@heroicons/react/24/outline';
import type { HomeSection, AudibleCategory } from '@/lib/hooks/useHomeSections';
import { authenticatedFetcher } from '@/lib/utils/api';
const MAX_SECTIONS = 10;
const SECTION_COLORS = [
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
];
function getSectionLabel(section: { sectionType: string; categoryName?: string | null }) {
if (section.sectionType === 'popular') return 'Popular Audiobooks';
if (section.sectionType === 'new_releases') return 'New Releases';
return section.categoryName || 'Category';
}
interface Props {
isOpen: boolean;
onClose: () => void;
sections: HomeSection[];
onSave: (sections: Omit<HomeSection, 'id'>[]) => Promise<unknown>;
}
export function HomeSectionConfigModal({ isOpen, onClose, sections, onSave }: Props) {
const [localSections, setLocalSections] = useState<Omit<HomeSection, 'id'>[]>([]);
const [categories, setCategories] = useState<AudibleCategory[]>([]);
const [loadingCategories, setLoadingCategories] = useState(false);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
// Sync from prop when modal opens
useEffect(() => {
if (isOpen) {
setLocalSections(
sections.map((s) => ({
sectionType: s.sectionType,
categoryId: s.categoryId,
categoryName: s.categoryName,
sortOrder: s.sortOrder,
}))
);
setDirty(false);
setShowCategoryPicker(false);
}
}, [isOpen, sections]);
// Auto-save with debounce
useEffect(() => {
if (!dirty) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setSaving(true);
try {
await onSave(localSections.map((s, i) => ({ ...s, sortOrder: i })));
} catch {
// Silently fail — user will see stale state
}
setSaving(false);
setDirty(false);
}, 800);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [dirty, localSections, onSave]);
// Fetch categories when picker opens
const loadCategories = useCallback(async () => {
if (categories.length > 0) {
setShowCategoryPicker(true);
return;
}
setLoadingCategories(true);
try {
const data = await authenticatedFetcher('/api/audible/categories');
setCategories(data.categories || []);
} catch {
setCategories([]);
}
setLoadingCategories(false);
setShowCategoryPicker(true);
}, [categories.length]);
const addCategory = useCallback(
(cat: AudibleCategory) => {
if (localSections.length >= MAX_SECTIONS) return;
// Prevent duplicate
if (localSections.some((s) => s.sectionType === 'category' && s.categoryId === cat.id)) return;
setLocalSections((prev) => [
...prev,
{
sectionType: 'category' as const,
categoryId: cat.id,
categoryName: cat.name,
sortOrder: prev.length,
},
]);
setDirty(true);
setShowCategoryPicker(false);
},
[localSections]
);
const addBuiltIn = useCallback(
(type: 'popular' | 'new_releases') => {
if (localSections.length >= MAX_SECTIONS) return;
if (localSections.some((s) => s.sectionType === type)) return;
setLocalSections((prev) => [
...prev,
{ sectionType: type, categoryId: null, categoryName: null, sortOrder: prev.length },
]);
setDirty(true);
},
[localSections]
);
const removeSection = useCallback((index: number) => {
setLocalSections((prev) => prev.filter((_, i) => i !== index));
setDirty(true);
}, []);
const moveSection = useCallback((from: number, to: number) => {
setLocalSections((prev) => {
const next = [...prev];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
});
setDirty(true);
}, []);
// Drag handlers
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
moveSection(dragIndex, index);
setDragIndex(index);
};
const handleDragEnd = () => {
setDragIndex(null);
};
if (!isOpen) return null;
const hasPopular = localSections.some((s) => s.sectionType === 'popular');
const hasNewReleases = localSections.some((s) => s.sectionType === 'new_releases');
const existingCategoryIds = new Set(
localSections.filter((s) => s.sectionType === 'category').map((s) => s.categoryId)
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Customize Home
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{localSections.length}/{MAX_SECTIONS} sections
{saving && (
<span className="ml-2 text-blue-500 dark:text-blue-400">Saving...</span>
)}
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Section list */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
{localSections.length === 0 && (
<div className="text-center text-gray-400 dark:text-gray-500 py-8">
<p className="text-sm">No sections configured.</p>
<p className="text-xs mt-1">Add sections below to customize your home page.</p>
</div>
)}
{localSections.map((section, index) => (
<div
key={`${section.sectionType}-${section.categoryId || index}`}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-200
${dragIndex === index
? 'border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md scale-[1.02]'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600'
}
`}
>
{/* Drag handle */}
<div className="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hidden sm:block">
<Bars3Icon className="w-4 h-4" />
</div>
{/* Color dot */}
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${SECTION_COLORS[index % SECTION_COLORS.length]}`} />
{/* Label */}
<span className="flex-1 text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
{getSectionLabel(section)}
</span>
{/* Mobile reorder arrows */}
<div className="flex sm:hidden gap-0.5">
<button
onClick={() => index > 0 && moveSection(index, index - 1)}
disabled={index === 0}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
aria-label="Move up"
>
<ChevronUpIcon className="w-4 h-4" />
</button>
<button
onClick={() => index < localSections.length - 1 && moveSection(index, index + 1)}
disabled={index === localSections.length - 1}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
aria-label="Move down"
>
<ChevronDownIcon className="w-4 h-4" />
</button>
</div>
{/* Remove */}
<button
onClick={() => removeSection(index)}
className="p-1 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
aria-label={`Remove ${getSectionLabel(section)}`}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Add section controls */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
{/* Built-in section buttons */}
<div className="flex gap-2 flex-wrap">
{!hasPopular && (
<button
onClick={() => addBuiltIn('popular')}
disabled={localSections.length >= MAX_SECTIONS}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
Popular
</button>
)}
{!hasNewReleases && (
<button
onClick={() => addBuiltIn('new_releases')}
disabled={localSections.length >= MAX_SECTIONS}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
New Releases
</button>
)}
<button
onClick={loadCategories}
disabled={localSections.length >= MAX_SECTIONS || loadingCategories}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-900/20 rounded-lg hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
{loadingCategories ? 'Loading...' : 'Category'}
</button>
</div>
{/* Category picker */}
{showCategoryPicker && (
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
{categories.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500">No categories found.</div>
) : (
categories
.filter((c) => !existingCategoryIds.has(c.id))
.map((cat) => (
<button
key={cat.id}
onClick={() => addCategory(cat)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-100 dark:border-gray-700/50 last:border-0"
>
{cat.name}
</button>
))
)}
<button
onClick={() => setShowCategoryPicker(false)}
className="w-full px-4 py-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
</div>
</div>
);
}