mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
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:
@@ -46,6 +46,8 @@ const getStatusConfig = (audiobook: Audiobook) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
|
||||
|
||||
export function AudiobookCard({
|
||||
audiobook,
|
||||
onRequestSuccess,
|
||||
@@ -57,6 +59,7 @@ export function AudiobookCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
|
||||
// Build a display-only audiobook with the local status override
|
||||
const displayAudiobook = localRequestStatus !== undefined
|
||||
@@ -113,20 +116,23 @@ export function AudiobookCard({
|
||||
`}
|
||||
>
|
||||
{/* Cover Art */}
|
||||
{audiobook.coverArtUrl ? (
|
||||
{audiobook.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={audiobook.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src={PLACEHOLDER_COVER}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover Overlay with Actions - Desktop Only
|
||||
|
||||
@@ -96,6 +96,7 @@ export function AudiobookDetailsModal({
|
||||
const [asinCopied, setAsinCopied] = useState(false);
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
|
||||
// Sync local status when the prop changes (e.g. page data refreshes)
|
||||
useEffect(() => {
|
||||
@@ -287,7 +288,7 @@ export function AudiobookDetailsModal({
|
||||
${squareCovers ? 'w-40 sm:w-44 lg:w-52 aspect-square' : 'w-32 sm:w-40 lg:w-48 aspect-[2/3]'}
|
||||
${status.type === 'available' ? 'ring-2 ring-emerald-400/60' : ''}
|
||||
`}>
|
||||
{audiobook.coverArtUrl ? (
|
||||
{audiobook.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={audiobook.coverArtUrl}
|
||||
alt=""
|
||||
@@ -295,13 +296,16 @@ export function AudiobookDetailsModal({
|
||||
className="object-cover"
|
||||
sizes="200px"
|
||||
priority
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src="/placeholder_cover.svg"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="200px"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rating Badge */}
|
||||
|
||||
@@ -250,10 +250,12 @@ export function BookPickerModal({
|
||||
{/* Cover Image or Text Placeholder */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-700 dark:to-gray-600">
|
||||
{book.coverUrl ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
alt={book.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-3">
|
||||
|
||||
@@ -27,6 +27,7 @@ export function RecommendationCard({
|
||||
isDraggable = true,
|
||||
}: RecommendationCardProps) {
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
@@ -227,7 +228,7 @@ export function RecommendationCard({
|
||||
|
||||
{/* Cover image - smaller on mobile to fit all content */}
|
||||
<div className="w-full relative bg-gray-200 dark:bg-gray-700 flex-shrink-0" style={{ maxHeight: 'min(25vh, 300px)' }}>
|
||||
{recommendation.coverUrl ? (
|
||||
{recommendation.coverUrl && !coverError ? (
|
||||
<Image
|
||||
src={recommendation.coverUrl}
|
||||
alt={recommendation.title}
|
||||
@@ -236,11 +237,17 @@ export function RecommendationCard({
|
||||
className="object-contain w-full h-auto"
|
||||
style={{ maxHeight: 'min(25vh, 300px)' }}
|
||||
unoptimized
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-48 flex items-center justify-center">
|
||||
<span className="text-6xl">📚</span>
|
||||
</div>
|
||||
<Image
|
||||
src="/placeholder_cover.svg"
|
||||
alt={recommendation.title}
|
||||
width={400}
|
||||
height={400}
|
||||
className="object-contain w-full h-auto"
|
||||
style={{ maxHeight: 'min(25vh, 300px)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -333,12 +333,14 @@ function CoverStack({
|
||||
onClick={() => book.asin && onBookClick(book.asin)}
|
||||
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
src={book.coverUrl || '/placeholder_cover.svg'}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -101,15 +101,14 @@ function WatchedSeriesCard({
|
||||
{/* Cover */}
|
||||
<button onClick={onNavigate} className="flex-shrink-0">
|
||||
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
|
||||
{item.coverArtUrl ? (
|
||||
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
src={item.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={item.seriesTitle}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const { squareCovers } = usePreferences();
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||
const [coverError, setCoverError] = React.useState(false);
|
||||
|
||||
const requestType = request.type || 'audiobook';
|
||||
const isEbook = requestType === 'ebook';
|
||||
@@ -98,41 +99,34 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
|
||||
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
|
||||
>
|
||||
{request.audiobook.coverArtUrl ? (
|
||||
{request.audiobook.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={request.audiobook.coverArtUrl}
|
||||
alt={request.audiobook.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
) : isEbook ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{isEbook ? (
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
style={{ color: '#f16f19' }}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src="/placeholder_cover.svg"
|
||||
alt={request.audiobook.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SeriesSummary } from '@/lib/hooks/useSeries';
|
||||
@@ -20,6 +20,7 @@ interface SeriesCardProps {
|
||||
}
|
||||
|
||||
export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
const visibleTags = series.tags.slice(0, 2);
|
||||
const hasTags = visibleTags.length > 0;
|
||||
const hasRating = series.rating != null && series.rating > 0;
|
||||
@@ -42,30 +43,23 @@ export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
|
||||
`}
|
||||
>
|
||||
{/* Cover Art or Fallback */}
|
||||
{series.coverArtUrl ? (
|
||||
{series.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={series.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600 to-teal-800 dark:from-emerald-700 dark:to-teal-900 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-1/3 h-1/3 text-white/40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.2}
|
||||
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src="/placeholder_cover.svg"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top-row badges — Rating (left) + Book count (right) */}
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { SeriesDetail } from '@/lib/hooks/useSeries';
|
||||
import { WatchSeriesButton } from '@/components/ui/WatchButton';
|
||||
|
||||
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
|
||||
|
||||
interface SeriesDetailCardProps {
|
||||
series: SeriesDetail;
|
||||
squareCovers?: boolean;
|
||||
@@ -20,6 +22,7 @@ interface SeriesDetailCardProps {
|
||||
|
||||
export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
const hasLongDescription = (series.description?.length || 0) > 300;
|
||||
|
||||
return (
|
||||
@@ -27,7 +30,7 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
|
||||
{/* Rectangular Cover */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`relative w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40`}>
|
||||
{series.books[0]?.coverArtUrl ? (
|
||||
{series.books[0]?.coverArtUrl && !coverError ? (
|
||||
<Image
|
||||
src={series.books[0].coverArtUrl}
|
||||
alt={series.title}
|
||||
@@ -35,13 +38,16 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
|
||||
priority
|
||||
onError={() => setCoverError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
|
||||
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<Image
|
||||
src={PLACEHOLDER_COVER}
|
||||
alt={series.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,21 +97,14 @@ export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = fa
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className={`relative w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300`}>
|
||||
{s.coverArtUrl ? (
|
||||
<Image
|
||||
src={s.coverArtUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-emerald-400 dark:text-emerald-300">
|
||||
{s.title.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
src={s.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="96px"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
* 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.
|
||||
* A single floating pill that automatically tracks which section dominates
|
||||
* the viewport and shows pagination controls for that section.
|
||||
* Supports 1-12 sections dynamically with dot indicators for manual switching.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -28,7 +27,7 @@ export interface PaginationSection {
|
||||
}
|
||||
|
||||
interface UnifiedPaginationProps {
|
||||
sections: [PaginationSection, PaginationSection];
|
||||
sections: PaginationSection[];
|
||||
footerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
@@ -91,34 +90,152 @@ function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section indicator dots — scales gracefully from 2-12 sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SectionDotsProps {
|
||||
sections: PaginationSection[];
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* For 2-4 sections: simple vertical dot column (original behavior, unchanged).
|
||||
* For 5+ sections: iOS-style compressed window of 5 visible dots.
|
||||
* - Center slot = active section (full height, accent color)
|
||||
* - ±1 slots = neighboring sections (medium)
|
||||
* - ±2 slots = far neighbors (tiny, fade indicator)
|
||||
* Dots beyond the window are hidden entirely. The window slides as activeIndex changes.
|
||||
*/
|
||||
function SectionDots({ sections, activeIndex }: SectionDotsProps) {
|
||||
const count = sections.length;
|
||||
|
||||
// ---- Few sections: simple column ----
|
||||
if (count <= 4) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pl-2 pr-3">
|
||||
{sections.map((section, idx) => {
|
||||
const isActive = idx === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={`${section.label}-${idx}`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Many sections: windowed 5-slot strip ----
|
||||
// The window is always 5 slots wide; we clamp it so it doesn't fall off edges.
|
||||
const WINDOW = 5;
|
||||
const half = Math.floor(WINDOW / 2); // 2
|
||||
|
||||
// Ideal window start: center the active dot
|
||||
let windowStart = activeIndex - half;
|
||||
// Clamp so window stays within [0, count - WINDOW]
|
||||
windowStart = Math.max(0, Math.min(windowStart, count - WINDOW));
|
||||
const windowEnd = windowStart + WINDOW - 1; // inclusive
|
||||
|
||||
// Distance from active within the window (for size calculation)
|
||||
// slots: [windowStart, windowStart+1, ..., windowEnd]
|
||||
const slots = Array.from({ length: WINDOW }, (_, i) => windowStart + i);
|
||||
|
||||
// Sizes: index 0 (dist 2 from active) → 2.5px, dist 1 → 4px, dist 0 (active) → 6px
|
||||
const heightForDist = [16, 10, 7, 5, 3]; // px — dist 0..4 (we only use 0-2)
|
||||
|
||||
// Whether we need overflow arrows (dots hidden beyond window edges)
|
||||
const hasHiddenLeft = windowStart > 0;
|
||||
const hasHiddenRight = windowEnd < count - 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5 pl-2 pr-3">
|
||||
{/* Top fade indicator */}
|
||||
{hasHiddenLeft && (
|
||||
<div
|
||||
className="w-0.5 rounded-full bg-gray-300 dark:bg-gray-600 opacity-30 flex-shrink-0"
|
||||
style={{ height: '3px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{slots.map((sectionIdx) => {
|
||||
const section = sections[sectionIdx];
|
||||
const isActive = sectionIdx === activeIndex;
|
||||
const dist = Math.abs(sectionIdx - activeIndex);
|
||||
const h = heightForDist[Math.min(dist, heightForDist.length - 1)];
|
||||
|
||||
// Active dot gets the section's accent color.
|
||||
// Inactive dots: the farther they are, the more faded.
|
||||
const opacityMap = [1, 0.55, 0.3];
|
||||
const opacity = opacityMap[Math.min(dist, opacityMap.length - 1)];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${section.label}-${sectionIdx}`}
|
||||
onClick={() => { if (!isActive) section.onScrollToSection(); }}
|
||||
disabled={isActive}
|
||||
title={section.label}
|
||||
aria-label={`Switch to ${section.label}`}
|
||||
style={{ height: `${h}px`, opacity }}
|
||||
className={`
|
||||
w-1.5 rounded-full flex-shrink-0
|
||||
transition-all duration-300 ease-out
|
||||
${isActive
|
||||
? `${section.accentColor} cursor-default`
|
||||
: 'bg-gray-400 dark:bg-gray-500 hover:opacity-90 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bottom fade indicator */}
|
||||
{hasHiddenRight && (
|
||||
<div
|
||||
className="w-0.5 rounded-full bg-gray-300 dark:bg-gray-600 opacity-30 flex-shrink-0"
|
||||
style={{ height: '3px' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</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 [activeIndex, setActiveIndex] = useState(0);
|
||||
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 ratiosRef = useRef<number[]>(sections.map(() => 0));
|
||||
const [anySectionVisible, setAnySectionVisible] = useState(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;
|
||||
// Keep ratios array length in sync with sections
|
||||
useEffect(() => {
|
||||
ratiosRef.current = sections.map((_, i) => ratiosRef.current[i] || 0);
|
||||
}, [sections.length]);
|
||||
|
||||
const activeSectionHasPages = sections[activeIndex]?.totalPages > 1;
|
||||
const shouldShow = anySectionVisible && !footerVisible && activeSectionHasPages && sections.length > 0;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Track which section each instance belongs to via intersection ratio
|
||||
// Intersection observers for all sections
|
||||
// ------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = [];
|
||||
@@ -128,38 +245,31 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio;
|
||||
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05;
|
||||
ratiosRef.current[idx] = entry.intersectionRatio;
|
||||
const anyVisible = ratiosRef.current.some((r) => r > 0.05);
|
||||
setAnySectionVisible(anyVisible);
|
||||
|
||||
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;
|
||||
// Find dominant section
|
||||
let maxRatio = -1;
|
||||
let dominant = 0;
|
||||
for (let i = 0; i < ratiosRef.current.length; i++) {
|
||||
if (ratiosRef.current[i] > maxRatio) {
|
||||
maxRatio = ratiosRef.current[i];
|
||||
dominant = i;
|
||||
}
|
||||
}
|
||||
|
||||
setActiveIndex((current) => {
|
||||
if (current !== dominant) {
|
||||
// Trigger cross-fade transition
|
||||
setIsTransitioning(true);
|
||||
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current);
|
||||
}
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
}, 320);
|
||||
|
||||
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',
|
||||
}
|
||||
@@ -173,8 +283,9 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
observers.forEach((o) => o.disconnect());
|
||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
// Re-run when section refs change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sections[0].sectionRef, sections[1].sectionRef]);
|
||||
}, [sections.map((s) => s.sectionRef.current).join(',')]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Footer observer
|
||||
@@ -190,9 +301,10 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
}, [footerRef]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derived values for the currently active section
|
||||
// Derived values
|
||||
// ------------------------------------------------------------------
|
||||
const active = sections[activeIndex];
|
||||
if (!active) return null;
|
||||
|
||||
const handlePrev = () => {
|
||||
if (active.currentPage > 1) active.onPageChange(active.currentPage - 1);
|
||||
@@ -231,32 +343,14 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
"
|
||||
>
|
||||
{/* 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>
|
||||
{sections.length > 1 && (
|
||||
<>
|
||||
<SectionDots sections={sections} activeIndex={activeIndex} />
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
|
||||
{/* 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
|
||||
@@ -265,11 +359,10 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
|
||||
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">
|
||||
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none max-w-[120px] truncate">
|
||||
{active.label}
|
||||
</span>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user