mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40: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:
@@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{issue.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
|
||||
+7
-17
@@ -176,23 +176,13 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{request.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={request.audiobook.coverArtUrl}
|
||||
alt={request.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={request.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Book Info */}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Component: Audible Categories API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Live scrape of top-level Audible categories for the home section config modal.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Audible.Categories');
|
||||
|
||||
/**
|
||||
* GET /api/audible/categories
|
||||
* Returns top-level Audible categories scraped live from audible.com/categories
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (_req: AuthenticatedRequest) => {
|
||||
try {
|
||||
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||
const audibleService = getAudibleService();
|
||||
const categories = await audibleService.getCategories();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
categories,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch categories', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch Audible categories' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: Category Audiobooks API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Serves audiobooks for a specific Audible category from AudibleCacheCategory,
|
||||
* with the same enrichment pattern as popular/new-releases routes.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/category/[categoryId]?page=1&limit=20&hideAvailable=false
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ categoryId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { categoryId } = await params;
|
||||
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';
|
||||
|
||||
if (page < 1 || limit < 1 || limit > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Invalid pagination parameters.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Get excluded ASINs when hideAvailable
|
||||
let excludedAsins: string[] = [];
|
||||
if (hideAvailable) {
|
||||
const availableSet = await getAvailableAsins();
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
// Query AudibleCacheCategory joined with AudibleCache
|
||||
const whereClause: any = { categoryId };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
if (totalCount === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: [],
|
||||
count: 0,
|
||||
totalCount: 0,
|
||||
page,
|
||||
totalPages: 0,
|
||||
hasMore: false,
|
||||
message: 'No audiobooks found for this category. Data may not have been refreshed yet.',
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Enrich with library matching and request status
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get category audiobooks', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch category audiobooks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
* Component: Audiobook Covers API Route
|
||||
* Documentation: documentation/frontend/pages/login.md
|
||||
*
|
||||
* Serves random popular audiobook covers for login page floating animations
|
||||
* Serves random popular audiobook covers for login page floating animations.
|
||||
* Queries AudibleCacheCategory with '__popular__' categoryId for cover sources.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||
|
||||
@@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers');
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Fetch all popular audiobooks with covers (up to 200)
|
||||
// Get popular ASINs from category table (up to 200)
|
||||
const categoryEntries = await prisma.audibleCacheCategory.findMany({
|
||||
where: { categoryId: POPULAR_CATEGORY_ID },
|
||||
orderBy: { rank: 'asc' },
|
||||
take: 200,
|
||||
select: { asin: true },
|
||||
});
|
||||
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
|
||||
// Fetch cover data from AudibleCache for popular ASINs with cached covers
|
||||
const audiobooks = await prisma.audibleCache.findMany({
|
||||
where: {
|
||||
isPopular: true,
|
||||
cachedCoverPath: {
|
||||
not: null,
|
||||
},
|
||||
asin: { in: asins },
|
||||
cachedCoverPath: { not: null },
|
||||
},
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
take: 200,
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Component: New Releases API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Serves new release audiobooks from audible_cache with real-time Plex matching
|
||||
* Serves new release audiobooks from AudibleCacheCategory with real-time library matching.
|
||||
* New releases are stored with categoryId '__new_releases__' in the unified category table.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -10,12 +11,13 @@ import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/new-releases?page=1&limit=20
|
||||
* Get new release audiobooks from audible_cache with pagination
|
||||
* Get new release audiobooks from AudibleCacheCategory with pagination
|
||||
*
|
||||
* Real-time matching against plex_library determines availability.
|
||||
*/
|
||||
@@ -46,39 +48,21 @@ export async function GET(request: NextRequest) {
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isNewRelease: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
// Query audible_cache for new release audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
// Query AudibleCacheCategory for new release audiobooks
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
newReleaseRank: 'asc',
|
||||
},
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: whereClause,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// If no data found, return helpful message
|
||||
@@ -95,30 +79,56 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Transform to matcher input format (uses ASIN as required field)
|
||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
||||
const audibleBooks = audiobooks.map((book) => {
|
||||
// Convert cached path to API URL if it exists
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Get current user (optional - for request status enrichment)
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
@@ -137,7 +147,7 @@ export async function GET(request: NextRequest) {
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Component: Popular Audiobooks API Route
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Serves popular audiobooks from audible_cache with real-time Plex matching
|
||||
* Serves popular audiobooks from AudibleCacheCategory with real-time library matching.
|
||||
* Popular books are stored with categoryId '__popular__' in the unified category table.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -10,12 +11,13 @@ import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
||||
|
||||
/**
|
||||
* GET /api/audiobooks/popular?page=1&limit=20
|
||||
* Get popular audiobooks from audible_cache with pagination
|
||||
* Get popular audiobooks from AudibleCacheCategory with pagination
|
||||
*
|
||||
* Real-time matching against plex_library determines availability.
|
||||
*/
|
||||
@@ -46,39 +48,21 @@ export async function GET(request: NextRequest) {
|
||||
excludedAsins = [...availableSet];
|
||||
}
|
||||
|
||||
const whereClause = {
|
||||
isPopular: true,
|
||||
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}),
|
||||
};
|
||||
const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
|
||||
if (excludedAsins.length > 0) {
|
||||
whereClause.asin = { notIn: excludedAsins };
|
||||
}
|
||||
|
||||
// Query audible_cache for popular audiobooks
|
||||
const [audiobooks, totalCount] = await Promise.all([
|
||||
prisma.audibleCache.findMany({
|
||||
// Query AudibleCacheCategory for popular audiobooks
|
||||
const [categoryEntries, totalCount] = await Promise.all([
|
||||
prisma.audibleCacheCategory.findMany({
|
||||
where: whereClause,
|
||||
orderBy: {
|
||||
popularRank: 'asc',
|
||||
},
|
||||
orderBy: { rank: 'asc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.audibleCache.count({
|
||||
where: whereClause,
|
||||
select: { asin: true, rank: true },
|
||||
}),
|
||||
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
// If no data found, return helpful message
|
||||
@@ -95,30 +79,56 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Transform to matcher input format (uses ASIN as required field)
|
||||
// Use cached cover path when available, otherwise fall back to coverArtUrl
|
||||
const audibleBooks = audiobooks.map((book) => {
|
||||
// Convert cached path to API URL if it exists
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
// Fetch full metadata from AudibleCache for these ASINs
|
||||
const asins = categoryEntries.map((e) => e.asin);
|
||||
const cacheEntries = await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: {
|
||||
asin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
narrator: true,
|
||||
description: true,
|
||||
coverArtUrl: true,
|
||||
cachedCoverPath: true,
|
||||
durationMinutes: true,
|
||||
releaseDate: true,
|
||||
rating: true,
|
||||
genres: true,
|
||||
lastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build a map for ordering by rank
|
||||
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||
|
||||
// Transform to matcher input format, preserving rank order
|
||||
const audibleBooks = categoryEntries
|
||||
.map((entry) => {
|
||||
const book = cacheMap.get(entry.asin);
|
||||
if (!book) return null;
|
||||
|
||||
let coverUrl = book.coverArtUrl || undefined;
|
||||
if (book.cachedCoverPath) {
|
||||
const filename = book.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
|
||||
return {
|
||||
asin: book.asin,
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
narrator: book.narrator || undefined,
|
||||
description: book.description || undefined,
|
||||
coverArtUrl: coverUrl,
|
||||
durationMinutes: book.durationMinutes || undefined,
|
||||
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||
genres: (book.genres as string[]) || [],
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as any[];
|
||||
|
||||
// Get current user (optional - for request status enrichment)
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
@@ -137,7 +147,7 @@ export async function GET(request: NextRequest) {
|
||||
page,
|
||||
totalPages,
|
||||
hasMore,
|
||||
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
|
||||
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Component: User Home Sections API Route
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Per-user configurable home page sections.
|
||||
* GET returns sections + next refresh time.
|
||||
* PUT saves full section config (delete-and-recreate in transaction).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.User.HomeSections');
|
||||
|
||||
const MAX_SECTIONS = 10;
|
||||
|
||||
const VALID_SECTION_TYPES = ['popular', 'new_releases', 'category'] as const;
|
||||
|
||||
const SectionSchema = z.object({
|
||||
sectionType: z.enum(VALID_SECTION_TYPES),
|
||||
categoryId: z.string().optional().nullable(),
|
||||
categoryName: z.string().optional().nullable(),
|
||||
sortOrder: z.number().int().min(0),
|
||||
});
|
||||
|
||||
const PutBodySchema = z.object({
|
||||
sections: z.array(SectionSchema).max(MAX_SECTIONS),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create default home sections for a new user (Popular + New Releases).
|
||||
*/
|
||||
async function ensureDefaultSections(userId: string) {
|
||||
const existing = await prisma.userHomeSection.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (existing.length > 0) return;
|
||||
|
||||
await prisma.userHomeSection.createMany({
|
||||
data: [
|
||||
{ userId, sectionType: 'popular', sortOrder: 0 },
|
||||
{ userId, sectionType: 'new_releases', sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/user/home-sections
|
||||
* Returns the user's configured home sections + next scheduled refresh time.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
await ensureDefaultSections(req.user.id);
|
||||
|
||||
const sections = await prisma.userHomeSection.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
// Get next refresh time from scheduled jobs
|
||||
let nextRefresh: string | null = null;
|
||||
try {
|
||||
const scheduledJob = await prisma.scheduledJob.findFirst({
|
||||
where: { type: 'audible_refresh', enabled: true },
|
||||
select: { nextRun: true },
|
||||
});
|
||||
nextRefresh = scheduledJob?.nextRun?.toISOString() || null;
|
||||
} catch {
|
||||
// Non-critical — just omit nextRefresh
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sections: sections.map((s) => ({
|
||||
id: s.id,
|
||||
sectionType: s.sectionType,
|
||||
categoryId: s.categoryId,
|
||||
categoryName: s.categoryName,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
nextRefresh,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get home sections', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch home sections' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/user/home-sections
|
||||
* Replaces all home sections for the user (delete-and-recreate in transaction).
|
||||
* Validates: max 10 sections, no duplicate sections, category sections need categoryId.
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { sections } = PutBodySchema.parse(body);
|
||||
|
||||
// Validate category sections have categoryId
|
||||
for (const section of sections) {
|
||||
if (section.sectionType === 'category' && !section.categoryId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Category sections require a categoryId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate section types (only one popular, one new_releases, unique categories)
|
||||
const seen = new Set<string>();
|
||||
for (const section of sections) {
|
||||
const key =
|
||||
section.sectionType === 'category'
|
||||
? `category:${section.categoryId}`
|
||||
: section.sectionType;
|
||||
if (seen.has(key)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: `Duplicate section: ${key}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
const userId = req.user.id;
|
||||
|
||||
// Delete-and-recreate in a transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userHomeSection.deleteMany({ where: { userId } });
|
||||
|
||||
if (sections.length > 0) {
|
||||
await tx.userHomeSection.createMany({
|
||||
data: sections.map((s, idx) => ({
|
||||
userId,
|
||||
sectionType: s.sectionType,
|
||||
categoryId: s.sectionType === 'category' ? s.categoryId : null,
|
||||
categoryName: s.sectionType === 'category' ? s.categoryName : null,
|
||||
sortOrder: idx,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Return the saved sections
|
||||
const saved = await prisma.userHomeSection.findMany({
|
||||
where: { userId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
logger.info(`User ${userId} updated home sections (${saved.length} sections)`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sections: saved.map((s) => ({
|
||||
id: s.id,
|
||||
sectionType: s.sectionType,
|
||||
categoryId: s.categoryId,
|
||||
categoryName: s.categoryName,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to save home sections', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'SaveError', message: 'Failed to save home sections' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -486,6 +486,7 @@ function LoginContent() {
|
||||
quality={70}
|
||||
priority={index < 10}
|
||||
loading={index < 10 ? 'eager' : 'lazy'}
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
</div>
|
||||
|
||||
+151
-170
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user