Add per-user home sections & unified Audible cache

Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor.
This commit is contained in:
kikootwo
2026-03-05 11:30:39 -05:00
parent 248bd5359c
commit cc8e106a2b
40 changed files with 2582 additions and 655 deletions
+4
View File
@@ -77,6 +77,7 @@
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md) - **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md) - **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
- **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md) - **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md)
- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md)
## BookDate (AI Recommendations) ## BookDate (AI Recommendations)
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md) - **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
@@ -150,3 +151,6 @@
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md) **"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) **"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives) **"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
**"How do I customize my home page?"** → [features/home-sections.md](features/home-sections.md)
**"How do Audible categories work?"** → [features/home-sections.md](features/home-sections.md)
**"How do I add category sections to the home page?"** → [features/home-sections.md](features/home-sections.md)
+4 -4
View File
@@ -129,10 +129,10 @@ interface ScheduledJob {
## Audible Refresh Processor ## Audible Refresh Processor
**Implementation:** **Implementation:**
1. Clear previous `isPopular`/`isNewRelease` flags 1. Fetch 200 popular + 200 new releases (multi-page scraping)
2. Fetch 200 popular + 200 new releases (multi-page scraping) 2. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`) 3. Wipe and re-populate `AudibleCacheCategory` entries with reserved IDs (`__popular__`, `__new_releases__`) and user-configured category IDs
4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths 4. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory`
5. Record sync timestamp (`lastAudibleSync`) 5. Record sync timestamp (`lastAudibleSync`)
6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache) 6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache)
7. Perform fuzzy matching (70% threshold) against Plex library 7. Perform fuzzy matching (70% threshold) against Plex library
+64
View File
@@ -0,0 +1,64 @@
# Home Page Sections (Per-User Configurable)
**Status:** Implemented | Per-user home page with configurable sections (popular, new releases, Audible categories)
## Overview
Users customize their home page by adding/removing/reordering sections. Each section displays audiobooks from a specific source: built-in Popular, New Releases, or scraped Audible categories.
## Data Models
**UserHomeSection** (`user_home_sections`):
- `id`, `userId` (FK User), `sectionType` ('popular'|'new_releases'|'category'), `categoryId` (nullable), `categoryName` (nullable), `sortOrder` (int)
- Unique: `(userId, sectionType, categoryId)`
- Default: Popular (0) + New Releases (1) created on first access
**AudibleCacheCategory** (`audible_cache_categories`):
- `id`, `asin`, `categoryId`, `rank`, `lastSyncedAt`
- Unique: `(asin, categoryId)`, Indexes: `categoryId`, `(categoryId, rank)`
## API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/user/home-sections` | user | Returns sections + nextRefresh |
| PUT | `/api/user/home-sections` | user | Save full config (delete-recreate), max 10 |
| GET | `/api/audible/categories` | user | Live scrape top-level categories |
| GET | `/api/audiobooks/category/[categoryId]` | public | Paginated category books from cache |
## Refresh Processor (Unified Storage)
- All section data stored in `AudibleCacheCategory` with reserved IDs: `__popular__` and `__new_releases__` for built-in sections
- Popular/new-releases use same wipe-and-populate pattern as user categories
- After built-in sections, queries DISTINCT categoryIds from `UserHomeSection`
- Per section: wipe `AudibleCacheCategory` rows, scrape, upsert `AudibleCache` metadata, insert ranked category entries
- Batch cooldown between sections (10-20s random)
- Constants exported from `audible-refresh.processor.ts`: `POPULAR_CATEGORY_ID`, `NEW_RELEASES_CATEGORY_ID`
## AudibleService Methods
- `getCategories()`: Scrapes `{baseUrl}/categories`, returns `{id, name}[]`
- `getCategoryBooks(categoryId, limit)`: Scrapes `/search?node={id}&pageSize=50&sort=popularity-rank`, up to 200 results
## Frontend
- **Hooks:** `useHomeSections()`, `useCategoryAudiobooks()`, `useAudibleCategories()` in `src/lib/hooks/useHomeSections.ts`
- **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce
- **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header
- **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize
- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — updated to support 1-12 dynamic sections
## Key Decisions
- 10 section limit per user (total)
- Category picker scraped live (no categories table)
- Top-level categories only (v1)
- Wipe-and-re-scrape per category during refresh
- Deduplication of categories across users before scraping
- If category disappears, user sees empty section
- 10-color palette assigned by sort order
## Files
- Schema: `prisma/schema.prisma` (UserHomeSection, AudibleCacheCategory)
- Migration: `prisma/migrations/20260306000000_add_home_sections/migration.sql`
- Service: `src/lib/integrations/audible.service.ts` (getCategories, getCategoryBooks)
- Processor: `src/lib/processors/audible-refresh.processor.ts`
- API Routes: `src/app/api/user/home-sections/route.ts`, `src/app/api/audible/categories/route.ts`, `src/app/api/audiobooks/category/[categoryId]/route.ts`
- Hooks: `src/lib/hooks/useHomeSections.ts`
- Components: `src/components/home/HomeSectionConfigModal.tsx`, `src/components/home/HomeSection.tsx`
- Tests: `tests/api/home-sections.routes.test.ts`, `tests/processors/audible-refresh.processor.test.ts`
+3 -3
View File
@@ -128,11 +128,11 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
Discovery APIs serve cached data from DB with real-time matching. Discovery APIs serve cached data from DB with real-time matching.
**Flow:** **Flow:**
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases 1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases + user-configured categories
2. Downloads and caches cover thumbnails locally (reduces Audible load) 2. Downloads and caches cover thumbnails locally (reduces Audible load)
3. Stores in DB with flags (`isPopular`, `isNewRelease`) and rankings 3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs
4. Cleans up unused thumbnails after sync 4. Cleans up unused thumbnails after sync
5. API routes query DB → apply real-time matching → return enriched results 5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results
6. Homepage loads instantly (no Audible API hits) 6. Homepage loads instantly (no Audible API hits)
## Thumbnail Caching ## Thumbnail Caching
@@ -0,0 +1,49 @@
-- CreateTable
CREATE TABLE "user_home_sections" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"section_type" TEXT NOT NULL,
"category_id" TEXT,
"category_name" TEXT,
"sort_order" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_home_sections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audible_cache_categories" (
"id" TEXT NOT NULL,
"asin" TEXT NOT NULL,
"category_id" TEXT NOT NULL,
"rank" INTEGER NOT NULL,
"last_synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audible_cache_categories_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "user_home_sections_user_id_idx" ON "user_home_sections"("user_id");
-- CreateIndex
CREATE INDEX "user_home_sections_sort_order_idx" ON "user_home_sections"("sort_order");
-- CreateIndex
CREATE UNIQUE INDEX "user_home_sections_user_id_section_type_category_id_key" ON "user_home_sections"("user_id", "section_type", "category_id");
-- CreateIndex
CREATE INDEX "audible_cache_categories_category_id_idx" ON "audible_cache_categories"("category_id");
-- CreateIndex
CREATE INDEX "audible_cache_categories_asin_idx" ON "audible_cache_categories"("asin");
-- CreateIndex
CREATE INDEX "audible_cache_categories_category_id_rank_idx" ON "audible_cache_categories"("category_id", "rank");
-- CreateIndex
CREATE UNIQUE INDEX "audible_cache_categories_asin_category_id_key" ON "audible_cache_categories"("asin", "category_id");
-- AddForeignKey
ALTER TABLE "user_home_sections" ADD CONSTRAINT "user_home_sections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
-- DropIndex
DROP INDEX IF EXISTS "audible_cache_is_popular_idx";
-- DropIndex
DROP INDEX IF EXISTS "audible_cache_is_new_release_idx";
-- DropIndex
DROP INDEX IF EXISTS "audible_cache_popular_rank_idx";
-- DropIndex
DROP INDEX IF EXISTS "audible_cache_new_release_rank_idx";
-- AlterTable - Remove legacy discovery flag columns (now stored in audible_cache_categories)
ALTER TABLE "audible_cache" DROP COLUMN "is_popular",
DROP COLUMN "is_new_release",
DROP COLUMN "popular_rank",
DROP COLUMN "new_release_rank";
+47 -10
View File
@@ -72,6 +72,7 @@ model User {
apiTokens ApiToken[] @relation("UserApiTokens") apiTokens ApiToken[] @relation("UserApiTokens")
watchedSeries WatchedSeries[] watchedSeries WatchedSeries[]
watchedAuthors WatchedAuthor[] watchedAuthors WatchedAuthor[]
homeSections UserHomeSection[]
@@index([plexId]) @@index([plexId])
@@index([role]) @@index([role])
@@ -98,12 +99,6 @@ model AudibleCache {
rating Decimal? @db.Decimal(3, 2) rating Decimal? @db.Decimal(3, 2)
genres Json @default("[]") genres Json @default("[]")
// Discovery categories
isPopular Boolean @default(false) @map("is_popular")
isNewRelease Boolean @default(false) @map("is_new_release")
popularRank Int? @map("popular_rank")
newReleaseRank Int? @map("new_release_rank")
lastSyncedAt DateTime @default(now()) @map("last_synced_at") lastSyncedAt DateTime @default(now()) @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -111,10 +106,6 @@ model AudibleCache {
@@index([asin]) @@index([asin])
@@index([title]) @@index([title])
@@index([author]) @@index([author])
@@index([isPopular])
@@index([isNewRelease])
@@index([popularRank])
@@index([newReleaseRank])
@@map("audible_cache") @@map("audible_cache")
} }
@@ -647,3 +638,49 @@ model WatchedAuthor {
@@index([authorAsin]) @@index([authorAsin])
@@map("watched_authors") @@map("watched_authors")
} }
// ============================================================================
// USER HOME SECTION TABLE
// Per-user configurable home page sections (popular, new_releases, category)
// Documentation: documentation/features/home-sections.md
// ============================================================================
model UserHomeSection {
id String @id @default(uuid())
userId String @map("user_id")
sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category'
categoryId String? @map("category_id") // Audible category node ID (only for type 'category')
categoryName String? @map("category_name") // Display name (only for type 'category')
sortOrder Int @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, sectionType, categoryId])
@@index([userId])
@@index([sortOrder])
@@map("user_home_sections")
}
// ============================================================================
// AUDIBLE CACHE CATEGORY TABLE
// Join table linking AudibleCache entries to Audible categories with ranking
// Documentation: documentation/features/home-sections.md
// ============================================================================
model AudibleCacheCategory {
id String @id @default(uuid())
asin String
categoryId String @map("category_id")
rank Int
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
createdAt DateTime @default(now()) @map("created_at")
@@unique([asin, categoryId])
@@index([categoryId])
@@index([asin])
@@index([categoryId, rank])
@@map("audible_cache_categories")
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 41.2 (35397) - http://www.bohemiancoding.com/sketch -->
<title>img-coverart</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="0" width="500" height="500"></rect>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Account-details:-membership-asin-doc" transform="translate(-87.000000, -867.000000)">
<g id="Group" transform="translate(65.000000, 780.000000)">
<g id="img-coverart" transform="translate(22.000000, 87.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="mask" fill="#BBBBBB" xlink:href="#path-1"></use>
<path d="M251.314605,307.191176 L126.315789,229.090627 L126.315789,250.186562 L251.314605,328.289474 L376.315789,250.186562 L376.315789,229.090627 L251.314605,307.191176 Z M300.338486,257.198698 L318.743522,245.697622 L318.757695,245.697622 C304.238718,223.902504 279.436447,209.540923 251.277279,209.540923 C223.146464,209.540923 198.363093,223.878883 183.839389,245.643293 L183.952782,245.655104 C184.933157,244.762229 185.930063,243.885889 186.955321,243.03317 C222.033803,213.960416 272.668324,220.342816 300.338486,257.198698 Z M214.370819,264.53208 C220.980666,259.892912 228.629944,257.226098 236.796575,257.226098 C250.228874,257.226098 262.283922,264.413975 270.556862,275.811119 L288.30989,264.716324 L288.319343,264.716324 C280.157438,253.040453 266.61174,245.39669 251.277753,245.39669 C236.026448,245.39669 222.549266,252.95778 214.370819,264.53208 Z M166.789394,213.901363 C218.255462,173.164548 291.088955,184.079823 329.878678,238.171964 L330.136173,238.568797 L349.186133,226.701596 C328.31953,194.777784 292.263042,173.684211 251.278701,173.684211 C210.866048,173.684211 174.47174,194.572281 153.416152,226.633095 C157.283311,222.56083 162.29621,217.458689 166.789394,213.901363 Z" id="icn-audible" fill="#FFFFFF" mask="url(#mask-2)"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
<div className="flex gap-3"> <div className="flex gap-3">
{/* Cover Image */} {/* Cover Image */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{issue.audiobook.coverArtUrl ? ( {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={issue.audiobook.coverArtUrl} src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
alt={issue.audiobook.title} alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover" className="w-16 h-16 rounded object-cover"
/> onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
) : ( />
<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>
)}
</div> </div>
{/* Info */} {/* Info */}
+7 -17
View File
@@ -176,23 +176,13 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
<div className="flex gap-3"> <div className="flex gap-3">
{/* Cover Image */} {/* Cover Image */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{request.audiobook.coverArtUrl ? ( {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={request.audiobook.coverArtUrl} src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
alt={request.audiobook.title} alt={request.audiobook.title}
className="w-16 h-16 rounded object-cover" className="w-16 h-16 rounded object-cover"
/> onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
) : ( />
<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>
)}
</div> </div>
{/* Book Info */} {/* Book Info */}
+39
View File
@@ -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 }
);
}
}
+16 -10
View File
@@ -2,12 +2,14 @@
* Component: Audiobook Covers API Route * Component: Audiobook Covers API Route
* Documentation: documentation/frontend/pages/login.md * 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 { NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.Covers'); const logger = RMABLogger.create('API.Audiobooks.Covers');
@@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers');
*/ */
export async function GET() { export async function GET() {
try { 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({ const audiobooks = await prisma.audibleCache.findMany({
where: { where: {
isPopular: true, asin: { in: asins },
cachedCoverPath: { cachedCoverPath: { not: null },
not: null,
},
}, },
orderBy: {
popularRank: 'asc',
},
take: 200,
select: { select: {
asin: true, asin: true,
title: true, title: true,
+63 -53
View File
@@ -2,7 +2,8 @@
* Component: New Releases API Route * Component: New Releases API Route
* Documentation: documentation/integrations/audible.md * 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'; import { NextRequest, NextResponse } from 'next/server';
@@ -10,12 +11,13 @@ import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth'; import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.NewReleases'); const logger = RMABLogger.create('API.Audiobooks.NewReleases');
/** /**
* GET /api/audiobooks/new-releases?page=1&limit=20 * 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. * Real-time matching against plex_library determines availability.
*/ */
@@ -46,39 +48,21 @@ export async function GET(request: NextRequest) {
excludedAsins = [...availableSet]; excludedAsins = [...availableSet];
} }
const whereClause = { const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID };
isNewRelease: true, if (excludedAsins.length > 0) {
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), whereClause.asin = { notIn: excludedAsins };
}; }
// Query audible_cache for new release audiobooks // Query AudibleCacheCategory for new release audiobooks
const [audiobooks, totalCount] = await Promise.all([ const [categoryEntries, totalCount] = await Promise.all([
prisma.audibleCache.findMany({ prisma.audibleCacheCategory.findMany({
where: whereClause, where: whereClause,
orderBy: { orderBy: { rank: 'asc' },
newReleaseRank: 'asc',
},
skip, skip,
take: limit, take: limit,
select: { select: { asin: true, rank: true },
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,
}), }),
prisma.audibleCacheCategory.count({ where: whereClause }),
]); ]);
// If no data found, return helpful message // 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) // Fetch full metadata from AudibleCache for these ASINs
// Use cached cover path when available, otherwise fall back to coverArtUrl const asins = categoryEntries.map((e) => e.asin);
const audibleBooks = audiobooks.map((book) => { const cacheEntries = await prisma.audibleCache.findMany({
// Convert cached path to API URL if it exists where: { asin: { in: asins } },
let coverUrl = book.coverArtUrl || undefined; select: {
if (book.cachedCoverPath) { asin: true,
const filename = book.cachedCoverPath.split('/').pop(); title: true,
coverUrl = `/api/cache/thumbnails/${filename}`; author: true,
} narrator: true,
description: true,
return { coverArtUrl: true,
asin: book.asin, cachedCoverPath: true,
title: book.title, durationMinutes: true,
author: book.author, releaseDate: true,
narrator: book.narrator || undefined, rating: true,
description: book.description || undefined, genres: true,
coverArtUrl: coverUrl, lastSyncedAt: true,
durationMinutes: book.durationMinutes || undefined, },
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
}); });
// 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) // Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request); const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined; const userId = currentUser?.sub || undefined;
@@ -137,7 +147,7 @@ export async function GET(request: NextRequest) {
page, page,
totalPages, totalPages,
hasMore, hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null, lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) });
+63 -53
View File
@@ -2,7 +2,8 @@
* Component: Popular Audiobooks API Route * Component: Popular Audiobooks API Route
* Documentation: documentation/integrations/audible.md * 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'; import { NextRequest, NextResponse } from 'next/server';
@@ -10,12 +11,13 @@ import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth'; import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
const logger = RMABLogger.create('API.Audiobooks.Popular'); const logger = RMABLogger.create('API.Audiobooks.Popular');
/** /**
* GET /api/audiobooks/popular?page=1&limit=20 * 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. * Real-time matching against plex_library determines availability.
*/ */
@@ -46,39 +48,21 @@ export async function GET(request: NextRequest) {
excludedAsins = [...availableSet]; excludedAsins = [...availableSet];
} }
const whereClause = { const whereClause: any = { categoryId: POPULAR_CATEGORY_ID };
isPopular: true, if (excludedAsins.length > 0) {
...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), whereClause.asin = { notIn: excludedAsins };
}; }
// Query audible_cache for popular audiobooks // Query AudibleCacheCategory for popular audiobooks
const [audiobooks, totalCount] = await Promise.all([ const [categoryEntries, totalCount] = await Promise.all([
prisma.audibleCache.findMany({ prisma.audibleCacheCategory.findMany({
where: whereClause, where: whereClause,
orderBy: { orderBy: { rank: 'asc' },
popularRank: 'asc',
},
skip, skip,
take: limit, take: limit,
select: { select: { asin: true, rank: true },
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,
}), }),
prisma.audibleCacheCategory.count({ where: whereClause }),
]); ]);
// If no data found, return helpful message // 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) // Fetch full metadata from AudibleCache for these ASINs
// Use cached cover path when available, otherwise fall back to coverArtUrl const asins = categoryEntries.map((e) => e.asin);
const audibleBooks = audiobooks.map((book) => { const cacheEntries = await prisma.audibleCache.findMany({
// Convert cached path to API URL if it exists where: { asin: { in: asins } },
let coverUrl = book.coverArtUrl || undefined; select: {
if (book.cachedCoverPath) { asin: true,
const filename = book.cachedCoverPath.split('/').pop(); title: true,
coverUrl = `/api/cache/thumbnails/${filename}`; author: true,
} narrator: true,
description: true,
return { coverArtUrl: true,
asin: book.asin, cachedCoverPath: true,
title: book.title, durationMinutes: true,
author: book.author, releaseDate: true,
narrator: book.narrator || undefined, rating: true,
description: book.description || undefined, genres: true,
coverArtUrl: coverUrl, lastSyncedAt: true,
durationMinutes: book.durationMinutes || undefined, },
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
}); });
// 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) // Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request); const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined; const userId = currentUser?.sub || undefined;
@@ -137,7 +147,7 @@ export async function GET(request: NextRequest) {
page, page,
totalPages, totalPages,
hasMore, hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null, lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) });
+202
View File
@@ -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 }
);
}
});
}
+1
View File
@@ -486,6 +486,7 @@ function LoginContent() {
quality={70} quality={70}
priority={index < 10} priority={index < 10}
loading={index < 10 ? 'eager' : 'lazy'} 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 className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</div> </div>
+151 -170
View File
@@ -1,208 +1,189 @@
/** /**
* Component: Homepage - Audiobook Discovery * Component: Homepage - Audiobook Discovery (Dynamic Sections)
* Documentation: documentation/frontend/components.md * Documentation: documentation/features/home-sections.md
*/ */
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback, createRef } from 'react';
import { Header } from '@/components/layout/Header'; 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 { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { UnifiedPagination } from '@/components/ui/UnifiedPagination'; import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
import { SectionToolbar } from '@/components/ui/SectionToolbar'; 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 { 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() { export default function HomePage() {
const [popularPage, setPopularPage] = useState(1); const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
const [newReleasesPage, setNewReleasesPage] = useState(1);
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences(); const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
// Refs for auto-scrolling to section tops // Per-section pagination state
const popularSectionRef = useRef<HTMLElement>(null); const [pages, setPages] = useState<Record<string, number>>({});
const newReleasesSectionRef = useRef<HTMLElement>(null); const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
const [configOpen, setConfigOpen] = useState(false);
const footerRef = useRef<HTMLElement>(null); const footerRef = useRef<HTMLElement>(null);
const { // Create stable refs for each section
audiobooks: popular, const sectionRefsMap = useRef<Map<string, React.RefObject<HTMLElement | null>>>(new Map());
isLoading: loadingPopular,
totalPages: popularTotalPages,
message: popularMessage,
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
const { const getSectionKey = (s: { sectionType: string; categoryId: string | null }) =>
audiobooks: newReleases, s.sectionType === 'category' ? `category:${s.categoryId}` : s.sectionType;
isLoading: loadingNewReleases,
totalPages: newReleasesTotalPages,
message: newReleasesMessage,
} = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable);
// 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(() => { useEffect(() => {
setPopularPage(1); setPages({});
setNewReleasesPage(1); setTotalPagesMap({});
}, [hideAvailable]); }, [hideAvailable]);
// Handle page changes with auto-scroll to section top const getPage = (key: string) => pages[key] || 1;
const handlePopularPageChange = (page: number) => { const setPage = useCallback((key: string, page: number) => {
setPopularPage(page); setPages((prev) => ({ ...prev, [key]: page }));
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, []);
}; const handleTotalPagesChange = useCallback((key: string, totalPages: number) => {
setTotalPagesMap((prev) => {
if (prev[key] === totalPages) return prev;
return { ...prev, [key]: totalPages };
});
}, []);
const handleNewReleasesPageChange = (page: number) => { // Build pagination sections for the floating pill
setNewReleasesPage(page); const paginationSections: PaginationSection[] = sections.map((s, i) => {
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); 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 ( return (
<ProtectedRoute> <ProtectedRoute>
<div className="min-h-screen"> <div className="min-h-screen">
<Header /> <Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12"> <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 */} {/* Loading state */}
<section ref={popularSectionRef} className="relative"> {sectionsLoading && (
{/* Sticky Section Header */} <div className="flex justify-center py-20">
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6"> <div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
<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>
<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"> {/* Empty state */}
Popular Audiobooks {!sectionsLoading && sections.length === 0 && (
</h2> <div className="text-center py-20">
<SectionToolbar <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} hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable} onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers} onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize} 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>
</div> </div>
</footer>
{/* Section Content */} {/* Unified Pagination — dynamic sections */}
<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"> {paginationSections.length > 0 && (
{popularMessage && !loadingPopular && popular.length === 0 ? ( <UnifiedPagination
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center"> footerRef={footerRef}
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium"> sections={paginationSections}
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>
{/* New Releases Section */} {/* Config Modal */}
<section ref={newReleasesSectionRef} className="relative"> <HomeSectionConfigModal
{/* Sticky Section Header */} isOpen={configOpen}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6"> onClose={() => setConfigOpen(false)}
<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"> sections={sections}
<div className="flex items-center gap-3"> onSave={saveSections}
<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' }),
},
]}
/>
</div> </div>
</ProtectedRoute> </ProtectedRoute>
); );
+12 -6
View File
@@ -46,6 +46,8 @@ const getStatusConfig = (audiobook: Audiobook) => {
return null; return null;
}; };
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
export function AudiobookCard({ export function AudiobookCard({
audiobook, audiobook,
onRequestSuccess, onRequestSuccess,
@@ -57,6 +59,7 @@ export function AudiobookCard({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined); const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
const [coverError, setCoverError] = useState(false);
// Build a display-only audiobook with the local status override // Build a display-only audiobook with the local status override
const displayAudiobook = localRequestStatus !== undefined const displayAudiobook = localRequestStatus !== undefined
@@ -113,20 +116,23 @@ export function AudiobookCard({
`} `}
> >
{/* Cover Art */} {/* Cover Art */}
{audiobook.coverArtUrl ? ( {audiobook.coverArtUrl && !coverError ? (
<Image <Image
src={audiobook.coverArtUrl} src={audiobook.coverArtUrl}
alt="" alt=""
fill fill
className="object-cover" className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw" 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"> <Image
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> src={PLACEHOLDER_COVER}
<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" /> alt=""
</svg> fill
</div> className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
)} )}
{/* Hover Overlay with Actions - Desktop Only {/* Hover Overlay with Actions - Desktop Only
@@ -96,6 +96,7 @@ export function AudiobookDetailsModal({
const [asinCopied, setAsinCopied] = useState(false); const [asinCopied, setAsinCopied] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null); const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const [coverError, setCoverError] = useState(false);
// Sync local status when the prop changes (e.g. page data refreshes) // Sync local status when the prop changes (e.g. page data refreshes)
useEffect(() => { 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]'} ${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' : ''} ${status.type === 'available' ? 'ring-2 ring-emerald-400/60' : ''}
`}> `}>
{audiobook.coverArtUrl ? ( {audiobook.coverArtUrl && !coverError ? (
<Image <Image
src={audiobook.coverArtUrl} src={audiobook.coverArtUrl}
alt="" alt=""
@@ -295,13 +296,16 @@ export function AudiobookDetailsModal({
className="object-cover" className="object-cover"
sizes="200px" sizes="200px"
priority 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"> <Image
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> src="/placeholder_cover.svg"
<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" /> alt=""
</svg> fill
</div> className="object-cover"
sizes="200px"
/>
)} )}
{/* Rating Badge */} {/* Rating Badge */}
@@ -250,10 +250,12 @@ export function BookPickerModal({
{/* Cover Image or Text Placeholder */} {/* 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"> <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 ? ( {book.coverUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={book.coverUrl} src={book.coverUrl}
alt={book.title} alt={book.title}
className="w-full h-full object-cover" 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"> <div className="w-full h-full flex flex-col items-center justify-center p-3">
+11 -4
View File
@@ -27,6 +27,7 @@ export function RecommendationCard({
isDraggable = true, isDraggable = true,
}: RecommendationCardProps) { }: RecommendationCardProps) {
const [showToast, setShowToast] = useState(false); const [showToast, setShowToast] = useState(false);
const [coverError, setCoverError] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -227,7 +228,7 @@ export function RecommendationCard({
{/* Cover image - smaller on mobile to fit all content */} {/* 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)' }}> <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 <Image
src={recommendation.coverUrl} src={recommendation.coverUrl}
alt={recommendation.title} alt={recommendation.title}
@@ -236,11 +237,17 @@ export function RecommendationCard({
className="object-contain w-full h-auto" className="object-contain w-full h-auto"
style={{ maxHeight: 'min(25vh, 300px)' }} style={{ maxHeight: 'min(25vh, 300px)' }}
unoptimized unoptimized
onError={() => setCoverError(true)}
/> />
) : ( ) : (
<div className="w-full h-48 flex items-center justify-center"> <Image
<span className="text-6xl">📚</span> src="/placeholder_cover.svg"
</div> alt={recommendation.title}
width={400}
height={400}
className="object-contain w-full h-auto"
style={{ maxHeight: 'min(25vh, 300px)' }}
/>
)} )}
</div> </div>
+310
View File
@@ -0,0 +1,310 @@
/**
* Component: Home Section renders a single audiobook discovery section
* Documentation: documentation/features/home-sections.md
*
* Handles popular, new_releases, and category section types with unified rendering.
*/
'use client';
import React, { useEffect } from 'react';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
import { useCategoryAudiobooks } from '@/lib/hooks/useHomeSections';
import { Cog6ToothIcon, ClockIcon } from '@heroicons/react/24/outline';
const SECTION_COLORS = [
'from-blue-500 to-indigo-500',
'from-emerald-500 to-teal-500',
'from-violet-500 to-purple-500',
'from-amber-500 to-orange-500',
'from-rose-500 to-pink-500',
'from-cyan-500 to-sky-500',
'from-fuchsia-500 to-pink-500',
'from-lime-500 to-green-500',
'from-orange-500 to-red-500',
'from-teal-500 to-emerald-500',
];
export const SECTION_DOT_COLORS = [
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
];
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
if (sectionType === 'popular') return 'Popular Audiobooks';
if (sectionType === 'new_releases') return 'New Releases';
return categoryName || 'Category';
}
/**
* Formats a nextRefresh ISO timestamp into a friendly, readable string.
* Examples: "today at 6:00 PM", "tomorrow at 2:00 AM", "Saturday at 9:00 AM"
*/
function formatNextRefresh(isoString: string): string {
const refreshDate = new Date(isoString);
const now = new Date();
const refreshMidnight = new Date(refreshDate);
refreshMidnight.setHours(0, 0, 0, 0);
const todayMidnight = new Date(now);
todayMidnight.setHours(0, 0, 0, 0);
const tomorrowMidnight = new Date(todayMidnight);
tomorrowMidnight.setDate(tomorrowMidnight.getDate() + 1);
const dayAfterMidnight = new Date(tomorrowMidnight);
dayAfterMidnight.setDate(dayAfterMidnight.getDate() + 1);
const timeStr = refreshDate.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
if (refreshMidnight.getTime() === todayMidnight.getTime()) {
return `today at ${timeStr}`;
}
if (refreshMidnight.getTime() === tomorrowMidnight.getTime()) {
return `tomorrow at ${timeStr}`;
}
if (refreshMidnight.getTime() < dayAfterMidnight.getTime()) {
const dayName = refreshDate.toLocaleDateString(undefined, { weekday: 'long' });
return `${dayName} at ${timeStr}`;
}
const dateStr = refreshDate.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
});
return `${dateStr} at ${timeStr}`;
}
interface HomeSectionProps {
sectionType: 'popular' | 'new_releases' | 'category';
categoryId: string | null;
categoryName: string | null;
colorIndex: number;
page: number;
onPageChange: (page: number) => void;
sectionRef: React.RefObject<HTMLElement | null>;
cardSize: number;
squareCovers: boolean;
hideAvailable: boolean;
onToggleHideAvailable: (v: boolean) => void;
onToggleSquareCovers: (v: boolean) => void;
onCardSizeChange: (v: number) => void;
onConfigOpen?: () => void;
onTotalPagesChange?: (totalPages: number) => void;
nextRefresh: string | null;
}
function PopularOrNewSection({
type,
page,
hideAvailable,
onTotalPagesChange,
...renderProps
}: {
type: 'popular' | 'new-releases';
page: number;
hideAvailable: boolean;
onTotalPagesChange?: (totalPages: number) => void;
} & RenderSectionProps) {
const { audiobooks, isLoading, totalPages, message } = useAudiobooks(type, 20, page, hideAvailable);
useEffect(() => {
onTotalPagesChange?.(totalPages);
}, [totalPages, onTotalPagesChange]);
return (
<RenderSection
audiobooks={audiobooks}
isLoading={isLoading}
totalPages={totalPages}
message={message}
{...renderProps}
/>
);
}
function CategorySection({
categoryId,
page,
hideAvailable,
onTotalPagesChange,
...renderProps
}: {
categoryId: string;
page: number;
hideAvailable: boolean;
onTotalPagesChange?: (totalPages: number) => void;
} & RenderSectionProps) {
const { audiobooks, isLoading, totalPages, message } = useCategoryAudiobooks(
categoryId,
20,
page,
hideAvailable
);
useEffect(() => {
onTotalPagesChange?.(totalPages);
}, [totalPages, onTotalPagesChange]);
return (
<RenderSection
audiobooks={audiobooks}
isLoading={isLoading}
totalPages={totalPages}
message={message}
{...renderProps}
/>
);
}
interface RenderSectionProps {
cardSize: number;
squareCovers: boolean;
nextRefresh?: string | null;
}
function CategoryEmptyState({ nextRefresh }: { nextRefresh?: string | null }) {
const refreshLabel = nextRefresh ? formatNextRefresh(nextRefresh) : null;
return (
<div className="flex flex-col items-center justify-center py-14 px-6 text-center">
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-gray-100 dark:bg-gray-700/60 mb-4">
<ClockIcon className="w-5 h-5 text-gray-400 dark:text-gray-500" />
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
No audiobooks yet
</p>
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs leading-relaxed">
{refreshLabel
? <>This section will fill in after the next data refresh, scheduled for <span className="text-gray-500 dark:text-gray-400">{refreshLabel}</span>.</>
: 'This section will fill in after the next scheduled data refresh.'}
</p>
</div>
);
}
function RenderSection({
audiobooks,
isLoading,
totalPages,
message,
cardSize,
squareCovers,
nextRefresh,
}: RenderSectionProps & {
audiobooks: any[];
isLoading: boolean;
totalPages: number;
message: string | null;
}) {
if (message && !isLoading && audiobooks.length === 0) {
return <CategoryEmptyState nextRefresh={nextRefresh} />;
}
return (
<AudiobookGrid
audiobooks={audiobooks}
isLoading={isLoading}
emptyMessage="No audiobooks available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
);
}
export function HomeSection({
sectionType,
categoryId,
categoryName,
colorIndex,
page,
onPageChange,
sectionRef,
cardSize,
squareCovers,
hideAvailable,
onToggleHideAvailable,
onToggleSquareCovers,
onCardSizeChange,
onConfigOpen,
onTotalPagesChange,
nextRefresh,
}: HomeSectionProps) {
const gradient = SECTION_COLORS[colorIndex % SECTION_COLORS.length];
const title = getSectionTitle(sectionType, categoryName);
const renderProps: RenderSectionProps = { cardSize, squareCovers, nextRefresh };
return (
<section ref={sectionRef} className="relative">
{/* Sticky Section Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className={`w-1 h-6 bg-gradient-to-b ${gradient} rounded-full`} />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
{title}
</h2>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={onToggleHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={onToggleSquareCovers}
cardSize={cardSize}
onCardSizeChange={onCardSizeChange}
/>
{onConfigOpen && (
<button
onClick={onConfigOpen}
className="p-1.5 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
aria-label="Customize home page"
title="Customize sections"
>
<Cog6ToothIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
{/* Section Content */}
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
{sectionType === 'popular' && (
<PopularOrNewSection
type="popular"
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
{sectionType === 'new_releases' && (
<PopularOrNewSection
type="new-releases"
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
{sectionType === 'category' && categoryId && (
<CategorySection
categoryId={categoryId}
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
</div>
</section>
);
}
@@ -0,0 +1,342 @@
/**
* Component: Home Section Configuration Modal
* Documentation: documentation/features/home-sections.md
*
* Allows users to add/remove/reorder home page sections.
* Drag-and-drop on desktop, up/down arrows on mobile. Auto-save with debounce.
*/
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
XMarkIcon,
PlusIcon,
TrashIcon,
ChevronUpIcon,
ChevronDownIcon,
Bars3Icon,
} from '@heroicons/react/24/outline';
import type { HomeSection, AudibleCategory } from '@/lib/hooks/useHomeSections';
import { authenticatedFetcher } from '@/lib/utils/api';
const MAX_SECTIONS = 10;
const SECTION_COLORS = [
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
];
function getSectionLabel(section: { sectionType: string; categoryName?: string | null }) {
if (section.sectionType === 'popular') return 'Popular Audiobooks';
if (section.sectionType === 'new_releases') return 'New Releases';
return section.categoryName || 'Category';
}
interface Props {
isOpen: boolean;
onClose: () => void;
sections: HomeSection[];
onSave: (sections: Omit<HomeSection, 'id'>[]) => Promise<unknown>;
}
export function HomeSectionConfigModal({ isOpen, onClose, sections, onSave }: Props) {
const [localSections, setLocalSections] = useState<Omit<HomeSection, 'id'>[]>([]);
const [categories, setCategories] = useState<AudibleCategory[]>([]);
const [loadingCategories, setLoadingCategories] = useState(false);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
// Sync from prop when modal opens
useEffect(() => {
if (isOpen) {
setLocalSections(
sections.map((s) => ({
sectionType: s.sectionType,
categoryId: s.categoryId,
categoryName: s.categoryName,
sortOrder: s.sortOrder,
}))
);
setDirty(false);
setShowCategoryPicker(false);
}
}, [isOpen, sections]);
// Auto-save with debounce
useEffect(() => {
if (!dirty) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setSaving(true);
try {
await onSave(localSections.map((s, i) => ({ ...s, sortOrder: i })));
} catch {
// Silently fail — user will see stale state
}
setSaving(false);
setDirty(false);
}, 800);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [dirty, localSections, onSave]);
// Fetch categories when picker opens
const loadCategories = useCallback(async () => {
if (categories.length > 0) {
setShowCategoryPicker(true);
return;
}
setLoadingCategories(true);
try {
const data = await authenticatedFetcher('/api/audible/categories');
setCategories(data.categories || []);
} catch {
setCategories([]);
}
setLoadingCategories(false);
setShowCategoryPicker(true);
}, [categories.length]);
const addCategory = useCallback(
(cat: AudibleCategory) => {
if (localSections.length >= MAX_SECTIONS) return;
// Prevent duplicate
if (localSections.some((s) => s.sectionType === 'category' && s.categoryId === cat.id)) return;
setLocalSections((prev) => [
...prev,
{
sectionType: 'category' as const,
categoryId: cat.id,
categoryName: cat.name,
sortOrder: prev.length,
},
]);
setDirty(true);
setShowCategoryPicker(false);
},
[localSections]
);
const addBuiltIn = useCallback(
(type: 'popular' | 'new_releases') => {
if (localSections.length >= MAX_SECTIONS) return;
if (localSections.some((s) => s.sectionType === type)) return;
setLocalSections((prev) => [
...prev,
{ sectionType: type, categoryId: null, categoryName: null, sortOrder: prev.length },
]);
setDirty(true);
},
[localSections]
);
const removeSection = useCallback((index: number) => {
setLocalSections((prev) => prev.filter((_, i) => i !== index));
setDirty(true);
}, []);
const moveSection = useCallback((from: number, to: number) => {
setLocalSections((prev) => {
const next = [...prev];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
});
setDirty(true);
}, []);
// Drag handlers
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
moveSection(dragIndex, index);
setDragIndex(index);
};
const handleDragEnd = () => {
setDragIndex(null);
};
if (!isOpen) return null;
const hasPopular = localSections.some((s) => s.sectionType === 'popular');
const hasNewReleases = localSections.some((s) => s.sectionType === 'new_releases');
const existingCategoryIds = new Set(
localSections.filter((s) => s.sectionType === 'category').map((s) => s.categoryId)
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Customize Home
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{localSections.length}/{MAX_SECTIONS} sections
{saving && (
<span className="ml-2 text-blue-500 dark:text-blue-400">Saving...</span>
)}
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Section list */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
{localSections.length === 0 && (
<div className="text-center text-gray-400 dark:text-gray-500 py-8">
<p className="text-sm">No sections configured.</p>
<p className="text-xs mt-1">Add sections below to customize your home page.</p>
</div>
)}
{localSections.map((section, index) => (
<div
key={`${section.sectionType}-${section.categoryId || index}`}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={`
flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-200
${dragIndex === index
? 'border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md scale-[1.02]'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600'
}
`}
>
{/* Drag handle */}
<div className="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hidden sm:block">
<Bars3Icon className="w-4 h-4" />
</div>
{/* Color dot */}
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${SECTION_COLORS[index % SECTION_COLORS.length]}`} />
{/* Label */}
<span className="flex-1 text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
{getSectionLabel(section)}
</span>
{/* Mobile reorder arrows */}
<div className="flex sm:hidden gap-0.5">
<button
onClick={() => index > 0 && moveSection(index, index - 1)}
disabled={index === 0}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
aria-label="Move up"
>
<ChevronUpIcon className="w-4 h-4" />
</button>
<button
onClick={() => index < localSections.length - 1 && moveSection(index, index + 1)}
disabled={index === localSections.length - 1}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
aria-label="Move down"
>
<ChevronDownIcon className="w-4 h-4" />
</button>
</div>
{/* Remove */}
<button
onClick={() => removeSection(index)}
className="p-1 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
aria-label={`Remove ${getSectionLabel(section)}`}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Add section controls */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
{/* Built-in section buttons */}
<div className="flex gap-2 flex-wrap">
{!hasPopular && (
<button
onClick={() => addBuiltIn('popular')}
disabled={localSections.length >= MAX_SECTIONS}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
Popular
</button>
)}
{!hasNewReleases && (
<button
onClick={() => addBuiltIn('new_releases')}
disabled={localSections.length >= MAX_SECTIONS}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
New Releases
</button>
)}
<button
onClick={loadCategories}
disabled={localSections.length >= MAX_SECTIONS || loadingCategories}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-900/20 rounded-lg hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
{loadingCategories ? 'Loading...' : 'Category'}
</button>
</div>
{/* Category picker */}
{showCategoryPicker && (
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
{categories.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500">No categories found.</div>
) : (
categories
.filter((c) => !existingCategoryIds.has(c.id))
.map((cat) => (
<button
key={cat.id}
onClick={() => addCategory(cat)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-100 dark:border-gray-700/50 last:border-0"
>
{cat.name}
</button>
))
)}
<button
onClick={() => setShowCategoryPicker(false)}
className="w-full px-4 py-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
</div>
</div>
);
}
@@ -333,12 +333,14 @@ function CoverStack({
onClick={() => book.asin && onBookClick(book.asin)} onClick={() => book.asin && onBookClick(book.asin)}
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined} title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={book.coverUrl} src={book.coverUrl || '/placeholder_cover.svg'}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
loading="lazy" loading="lazy"
draggable={false} draggable={false}
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/> />
</div> </div>
))} ))}
@@ -101,15 +101,14 @@ function WatchedSeriesCard({
{/* Cover */} {/* Cover */}
<button onClick={onNavigate} className="flex-shrink-0"> <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`}> <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
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" /> src={item.coverArtUrl || '/placeholder_cover.svg'}
) : ( alt={item.seriesTitle}
<div className="absolute inset-0 flex items-center justify-center"> fill
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> className="object-cover"
<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" /> sizes="56px"
</svg> onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
</div> />
)}
</div> </div>
</button> </button>
+20 -26
View File
@@ -44,6 +44,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const { squareCovers } = usePreferences(); const { squareCovers } = usePreferences();
const [showError, setShowError] = React.useState(false); const [showError, setShowError] = React.useState(false);
const [showDetailsModal, setShowDetailsModal] = React.useState(false); const [showDetailsModal, setShowDetailsModal] = React.useState(false);
const [coverError, setCoverError] = React.useState(false);
const requestType = request.type || 'audiobook'; const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook'; const isEbook = requestType === 'ebook';
@@ -98,41 +99,34 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
tabIndex={request.audiobook.audibleAsin ? 0 : undefined} tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)} onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
> >
{request.audiobook.coverArtUrl ? ( {request.audiobook.coverArtUrl && !coverError ? (
<Image <Image
src={request.audiobook.coverArtUrl} src={request.audiobook.coverArtUrl}
alt={request.audiobook.title} alt={request.audiobook.title}
fill fill
className="object-cover" className="object-cover"
sizes="96px" sizes="96px"
onError={() => setCoverError(true)}
/> />
) : ( ) : isEbook ? (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center">
{isEbook ? ( <svg
<svg className="w-12 h-12"
className="w-12 h-12" style={{ color: '#f16f19' }}
style={{ color: '#f16f19' }} fill="currentColor"
fill="currentColor" viewBox="0 0 24 24"
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" />
<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>
) : (
<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>
)}
</div> </div>
) : (
<Image
src="/placeholder_cover.svg"
alt={request.audiobook.title}
fill
className="object-cover"
sizes="96px"
/>
)} )}
</div> </div>
</div> </div>
+11 -17
View File
@@ -9,7 +9,7 @@
'use client'; 'use client';
import React from 'react'; import React, { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { SeriesSummary } from '@/lib/hooks/useSeries'; import { SeriesSummary } from '@/lib/hooks/useSeries';
@@ -20,6 +20,7 @@ interface SeriesCardProps {
} }
export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) { export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
const [coverError, setCoverError] = useState(false);
const visibleTags = series.tags.slice(0, 2); const visibleTags = series.tags.slice(0, 2);
const hasTags = visibleTags.length > 0; const hasTags = visibleTags.length > 0;
const hasRating = series.rating != null && series.rating > 0; const hasRating = series.rating != null && series.rating > 0;
@@ -42,30 +43,23 @@ export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) {
`} `}
> >
{/* Cover Art or Fallback */} {/* Cover Art or Fallback */}
{series.coverArtUrl ? ( {series.coverArtUrl && !coverError ? (
<Image <Image
src={series.coverArtUrl} src={series.coverArtUrl}
alt="" alt=""
fill fill
className="object-cover" className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw" 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"> <Image
<svg src="/placeholder_cover.svg"
className="w-1/3 h-1/3 text-white/40" alt=""
fill="none" fill
stroke="currentColor" className="object-cover"
viewBox="0 0 24 24" sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
> />
<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>
)} )}
{/* Top-row badges — Rating (left) + Book count (right) */} {/* Top-row badges — Rating (left) + Book count (right) */}
+13 -7
View File
@@ -8,11 +8,13 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { SeriesDetail } from '@/lib/hooks/useSeries'; import { SeriesDetail } from '@/lib/hooks/useSeries';
import { WatchSeriesButton } from '@/components/ui/WatchButton'; import { WatchSeriesButton } from '@/components/ui/WatchButton';
const PLACEHOLDER_COVER = '/placeholder_cover.svg';
interface SeriesDetailCardProps { interface SeriesDetailCardProps {
series: SeriesDetail; series: SeriesDetail;
squareCovers?: boolean; squareCovers?: boolean;
@@ -20,6 +22,7 @@ interface SeriesDetailCardProps {
export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) { export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [coverError, setCoverError] = useState(false);
const hasLongDescription = (series.description?.length || 0) > 300; const hasLongDescription = (series.description?.length || 0) > 300;
return ( return (
@@ -27,7 +30,7 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
{/* Rectangular Cover */} {/* Rectangular Cover */}
<div className="flex-shrink-0"> <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`}> <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 <Image
src={series.books[0].coverArtUrl} src={series.books[0].coverArtUrl}
alt={series.title} alt={series.title}
@@ -35,13 +38,16 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
className="object-cover" className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px" sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
priority 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"> <Image
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> src={PLACEHOLDER_COVER}
<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" /> alt={series.title}
</svg> fill
</div> className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
/>
)} )}
</div> </div>
</div> </div>
+8 -15
View File
@@ -97,21 +97,14 @@ export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = fa
> >
{/* Cover */} {/* 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`}> <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
<Image src={s.coverArtUrl || '/placeholder_cover.svg'}
src={s.coverArtUrl} alt=""
alt="" fill
fill className="object-cover"
className="object-cover" sizes="96px"
sizes="96px" onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/> />
) : (
<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>
)}
</div> </div>
{/* Title */} {/* Title */}
+164 -71
View File
@@ -2,10 +2,9 @@
* Component: Unified Pagination context-aware floating paginator * Component: Unified Pagination context-aware floating paginator
* Documentation: documentation/frontend/components.md * Documentation: documentation/frontend/components.md
* *
* Replaces two overlapping StickyPagination instances with a single pill * A single floating pill that automatically tracks which section dominates
* that automatically tracks which section dominates the viewport and shows * the viewport and shows pagination controls for that section.
* controls for that section. Transitions smoothly when the dominant section * Supports 1-12 sections dynamically with dot indicators for manual switching.
* changes. Includes a two-dot section indicator for manual switching.
*/ */
'use client'; 'use client';
@@ -28,7 +27,7 @@ export interface PaginationSection {
} }
interface UnifiedPaginationProps { interface UnifiedPaginationProps {
sections: [PaginationSection, PaginationSection]; sections: PaginationSection[];
footerRef?: React.RefObject<HTMLElement | null>; 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 // Main component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) { export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
// Index of the currently dominant section (0 or 1) const [activeIndex, setActiveIndex] = useState(0);
const [activeIndex, setActiveIndex] = useState<0 | 1>(0);
// Whether the label+controls area is mid-transition (drives opacity fade)
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const [footerVisible, setFooterVisible] = useState(false); const [footerVisible, setFooterVisible] = useState(false);
// Per-section raw intersection ratios [0,1] const ratiosRef = useRef<number[]>(sections.map(() => 0));
const ratiosRef = useRef<[number, number]>([0, 0]); const [anySectionVisible, setAnySectionVisible] = useState(false);
// Whether each section has any meaningful intersection
const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]);
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Determine if the pill should be shown at all: // Keep ratios array length in sync with sections
// - at least one section is meaningfully visible useEffect(() => {
// - footer is not visible ratiosRef.current = sections.map((_, i) => ratiosRef.current[i] || 0);
// - the active section has >1 page }, [sections.length]);
const activeSectionHasPages = sections[activeIndex].totalPages > 1;
const eitherSectionVisible = sectionVisible[0] || sectionVisible[1]; const activeSectionHasPages = sections[activeIndex]?.totalPages > 1;
const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages; const shouldShow = anySectionVisible && !footerVisible && activeSectionHasPages && sections.length > 0;
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Track which section each instance belongs to via intersection ratio // Intersection observers for all sections
// ------------------------------------------------------------------ // ------------------------------------------------------------------
useEffect(() => { useEffect(() => {
const observers: IntersectionObserver[] = []; const observers: IntersectionObserver[] = [];
@@ -128,38 +245,31 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio; ratiosRef.current[idx] = entry.intersectionRatio;
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05; const anyVisible = ratiosRef.current.some((r) => r > 0.05);
setAnySectionVisible(anyVisible);
setSectionVisible((prev) => { // Find dominant section
const next: [boolean, boolean] = [...prev] as [boolean, boolean]; let maxRatio = -1;
next[idx as 0 | 1] = isVisible; let dominant = 0;
return next; for (let i = 0; i < ratiosRef.current.length; i++) {
}); if (ratiosRef.current[i] > maxRatio) {
maxRatio = ratiosRef.current[i];
// Determine dominant section (whichever has more viewport coverage) dominant = i;
const [r0, r1] = ratiosRef.current; }
const dominant: 0 | 1 = r0 >= r1 ? 0 : 1; }
setActiveIndex((current) => { setActiveIndex((current) => {
if (current !== dominant) { if (current !== dominant) {
// Trigger cross-fade transition
setIsTransitioning(true); setIsTransitioning(true);
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
if (transitionTimerRef.current) { transitionTimerRef.current = setTimeout(() => setIsTransitioning(false), 320);
clearTimeout(transitionTimerRef.current);
}
transitionTimerRef.current = setTimeout(() => {
setIsTransitioning(false);
}, 320);
return dominant; return dominant;
} }
return current; return current;
}); });
}, },
{ {
// Dense threshold array gives us smooth ratio tracking
threshold: Array.from({ length: 21 }, (_, i) => i / 20), threshold: Array.from({ length: 21 }, (_, i) => i / 20),
rootMargin: '-60px 0px -80px 0px', rootMargin: '-60px 0px -80px 0px',
} }
@@ -173,8 +283,9 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
observers.forEach((o) => o.disconnect()); observers.forEach((o) => o.disconnect());
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
}; };
// Re-run when section refs change
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sections[0].sectionRef, sections[1].sectionRef]); }, [sections.map((s) => s.sectionRef.current).join(',')]);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Footer observer // Footer observer
@@ -190,9 +301,10 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
}, [footerRef]); }, [footerRef]);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Derived values for the currently active section // Derived values
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const active = sections[activeIndex]; const active = sections[activeIndex];
if (!active) return null;
const handlePrev = () => { const handlePrev = () => {
if (active.currentPage > 1) active.onPageChange(active.currentPage - 1); if (active.currentPage > 1) active.onPageChange(active.currentPage - 1);
@@ -231,32 +343,14 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
" "
> >
{/* Section selector dots — left side */} {/* Section selector dots — left side */}
<div className="flex flex-col gap-1 pl-2 pr-3"> {sections.length > 1 && (
{sections.map((section, idx) => { <>
const isActive = idx === activeIndex; <SectionDots sections={sections} activeIndex={activeIndex} />
return (
<button
key={section.label}
onClick={() => {
if (!isActive) section.onScrollToSection();
}}
disabled={isActive}
title={section.label}
aria-label={`Switch to ${section.label}`}
className={`
w-1.5 rounded-full transition-all duration-300 ease-out
${isActive
? `${section.accentColor} h-4 opacity-100`
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
}
`}
/>
);
})}
</div>
{/* Divider */} {/* Divider */}
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" /> <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 */} {/* Label + controls — cross-fades on section switch */}
<div <div
@@ -265,11 +359,10 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
transition-opacity duration-200 ease-in-out transition-opacity duration-200 ease-in-out
${isTransitioning ? 'opacity-0' : 'opacity-100'} ${isTransitioning ? 'opacity-0' : 'opacity-100'}
`} `}
// key forces full remount on switch so input state resets cleanly
key={activeIndex} key={activeIndex}
> >
{/* Section label — hidden on small screens */} {/* 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} {active.label}
</span> </span>
+119
View File
@@ -0,0 +1,119 @@
/**
* Component: Home Sections Hook
* Documentation: documentation/features/home-sections.md
*
* Manages user home section configuration (CRUD) and category fetching.
*/
'use client';
import useSWR, { mutate as globalMutate } from 'swr';
import { authenticatedFetcher } from '@/lib/utils/api';
import { useCallback, useRef } from 'react';
export interface HomeSection {
id: string;
sectionType: 'popular' | 'new_releases' | 'category';
categoryId: string | null;
categoryName: string | null;
sortOrder: number;
}
export interface HomeSectionsResponse {
success: boolean;
sections: HomeSection[];
nextRefresh: string | null;
}
export interface AudibleCategory {
id: string;
name: string;
}
const HOME_SECTIONS_KEY = '/api/user/home-sections';
/**
* Hook to fetch and manage user home sections.
*/
export function useHomeSections() {
const { data, error, isLoading, mutate } = useSWR<HomeSectionsResponse>(
HOME_SECTIONS_KEY,
authenticatedFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 30000,
}
);
const saveSections = useCallback(
async (sections: Omit<HomeSection, 'id'>[]) => {
const { fetchJSON } = await import('@/lib/utils/api');
const result = await fetchJSON<HomeSectionsResponse>(HOME_SECTIONS_KEY, {
method: 'PUT',
body: JSON.stringify({ sections }),
});
// Update local cache
mutate(result, false);
return result;
},
[mutate]
);
return {
sections: data?.sections || [],
nextRefresh: data?.nextRefresh || null,
isLoading,
error,
saveSections,
mutate,
};
}
/**
* Hook to fetch Audible categories (live scrape, for config modal).
*/
export function useAudibleCategories() {
const { data, error, isLoading } = useSWR<{ success: boolean; categories: AudibleCategory[] }>(
null, // Don't fetch automatically — use fetchCategories
authenticatedFetcher,
{ revalidateOnFocus: false }
);
return {
categories: data?.categories || [],
isLoading,
error,
};
}
/**
* Hook to fetch category audiobooks (same pattern as useAudiobooks).
*/
export function useCategoryAudiobooks(
categoryId: string | null,
limit: number = 20,
page: number = 1,
hideAvailable: boolean = false
) {
const hideParam = hideAvailable ? '&hideAvailable=true' : '';
const endpoint = categoryId
? `/api/audiobooks/category/${categoryId}?page=${page}&limit=${limit}${hideParam}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 60000,
});
return {
audiobooks: data?.audiobooks || [],
totalCount: data?.totalCount || 0,
totalPages: data?.totalPages || 0,
currentPage: data?.page || page,
hasMore: data?.hasMore || false,
message: data?.message || null,
isLoading,
error,
};
}
+158
View File
@@ -256,6 +256,15 @@ export class AudibleService {
throw error; throw error;
} }
// Don't retry on deterministic 500 errors (e.g. "Release date is in the future")
if (status === 500) {
const message = error.response?.data?.message || '';
if (message.includes('Release date is in the future')) {
logger.info(` External API returned non-retryable error: ${message}`);
throw error;
}
}
// Don't retry on last attempt // Don't retry on last attempt
if (attempt === maxRetries) { if (attempt === maxRetries) {
break; break;
@@ -1172,6 +1181,155 @@ export class AudibleService {
} }
} }
/**
* Get top-level categories from Audible's categories page.
* Scrapes {baseUrl}/categories and returns {id, name}[] for top-level nodes.
*/
async getCategories(): Promise<{ id: string; name: string }[]> {
await this.initialize();
logger.info('Fetching Audible categories...');
try {
const { data: response } = await this.fetchWithRetry('/categories', {
params: { ipRedirectOverride: 'true' },
});
const $ = cheerio.load(response.data);
const categories: { id: string; name: string }[] = [];
// Top-level category links are in the main categories grid
// They follow the pattern /cat/{name}/{nodeId}
$('a[href*="/cat/"]').each((_index, element) => {
const $el = $(element);
const href = $el.attr('href') || '';
const match = href.match(/\/cat\/[^\/]+\/(\d+)/);
if (!match) return;
const id = match[1];
const name = $el.text().trim();
if (name && !categories.some((c) => c.id === id)) {
categories.push({ id, name });
}
});
logger.info(`Found ${categories.length} top-level categories`);
return categories;
} catch (error) {
logger.error('Failed to fetch categories', {
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Get audiobooks for a specific category using Audible search with node parameter.
* Scrapes {baseUrl}/search?node={categoryId}&pageSize=50, up to `limit` results.
*/
async getCategoryBooks(categoryId: string, limit: number = 200): Promise<AudibleAudiobook[]> {
await this.initialize();
logger.info(`Fetching category books for node ${categoryId} (limit: ${limit})...`);
const audiobooks: AudibleAudiobook[] = [];
let page = 1;
const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE);
this.pacer.reset();
while (audiobooks.length < limit && page <= maxPages) {
try {
const { data: response, meta } = await this.fetchWithRetry('/search', {
params: {
ipRedirectOverride: 'true',
node: categoryId,
pageSize: AUDIBLE_PAGE_SIZE,
sort: 'popularity-rank',
...(page > 1 ? { page } : {}),
},
});
const $ = cheerio.load(response.data);
let foundOnPage = 0;
// Parse search results — same selectors as search()
$('.s-result-item, .productListItem').each((_index, element) => {
if (audiobooks.length >= limit) return false;
const $el = $(element);
const asin =
$el.find('li').attr('data-asin') ||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
'';
if (!asin || audiobooks.some((b) => b.asin === asin)) return;
const title =
$el.find('h2').first().text().trim() ||
$el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorLink = $el.find('a[href*="/author/"]').first();
const authorText =
authorLink.text().trim() ||
$el.find('.authorLabel').text().trim();
const authorHref = authorLink.attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
const narratorText =
$el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const langConfig = this.getLangConfig();
const runtimeText =
$el.find('.runtimeLabel').text().trim() ||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
const ratingText =
$el.find('.ratingsLabel').text().trim() ||
$el.find('.a-icon-star span').first().text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
audiobooks.push({
asin,
title,
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
rating,
});
foundOnPage++;
});
logger.info(`Category ${categoryId}: found ${foundOnPage} books on page ${page}`);
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) break;
page++;
if (page <= maxPages && audiobooks.length < limit) {
await this.delay(this.pacer.reportPageResult(meta));
}
} catch (error) {
logger.error(`Failed to fetch category ${categoryId} page ${page}`, {
error: error instanceof Error ? error.message : String(error),
collectedSoFar: audiobooks.length,
});
break;
}
}
logger.info(`Category ${categoryId}: collected ${audiobooks.length} books across ${page - 1} pages`);
return audiobooks;
}
/** /**
* Add delay between requests to respect rate limits * Add delay between requests to respect rate limits
*/ */
+144 -118
View File
@@ -2,12 +2,18 @@
* Component: Audible Refresh Processor * Component: Audible Refresh Processor
* Documentation: documentation/backend/services/scheduler.md * Documentation: documentation/backend/services/scheduler.md
* *
* Fetches popular and new release audiobooks from Audible and caches them * Fetches popular, new release, and category audiobooks from Audible and caches them.
* All section data is stored uniformly in AudibleCacheCategory with reserved IDs
* '__popular__' and '__new_releases__' for built-in sections.
*/ */
import { prisma } from '../db'; import { prisma } from '../db';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
/** Reserved category IDs for built-in home sections */
export const POPULAR_CATEGORY_ID = '__popular__';
export const NEW_RELEASES_CATEGORY_ID = '__new_releases__';
export interface AudibleRefreshPayload { export interface AudibleRefreshPayload {
jobId?: string; jobId?: string;
scheduledJobId?: string; scheduledJobId?: string;
@@ -25,22 +31,7 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
const thumbnailCache = getThumbnailCacheService(); const thumbnailCache = getThumbnailCacheService();
try { try {
// Clear previous popular/new-release flags for fresh data const syncTime = new Date();
await prisma.audibleCache.updateMany({
where: {
OR: [
{ isPopular: true },
{ isNewRelease: true },
],
},
data: {
isPopular: false,
isNewRelease: false,
popularRank: null,
newReleaseRank: null,
},
});
logger.info('Cleared previous popular/new-release flags in audible_cache');
// Fetch popular and new releases - 200 items each // Fetch popular and new releases - 200 items each
const popular = await audibleService.getPopularAudiobooks(200); const popular = await audibleService.getPopularAudiobooks(200);
@@ -54,113 +45,63 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`); logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
// Persist to audible_cache // Persist popular audiobooks via AudibleCacheCategory
let popularSaved = 0; const popularSaved = await persistSectionBooks(
let newReleasesSaved = 0; popular, POPULAR_CATEGORY_ID, syncTime, thumbnailCache, logger, 'popular audiobook'
const syncTime = new Date(); );
for (let i = 0; i < popular.length; i++) { // Persist new releases via AudibleCacheCategory
const audiobook = popular[i]; const newReleasesSaved = await persistSectionBooks(
try { newReleases, NEW_RELEASES_CATEGORY_ID, syncTime, thumbnailCache, logger, 'new release'
// Cache thumbnail if coverArtUrl exists );
let cachedCoverPath: string | null = null;
if (audiobook.coverArtUrl) { logger.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases`);
cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl);
// --- Category scraping ---
// Query distinct categoryIds from all users' home sections
let categoriesSynced = 0;
try {
const categorySections = await prisma.userHomeSection.findMany({
where: { sectionType: 'category', categoryId: { not: null } },
select: { categoryId: true },
distinct: ['categoryId'],
});
const categoryIds = categorySections
.map((s) => s.categoryId)
.filter((id): id is string => id !== null);
if (categoryIds.length > 0) {
logger.info(`Refreshing ${categoryIds.length} user-configured categories...`);
for (const catId of categoryIds) {
try {
// Batch cooldown between categories
const catCooldownMs = 10000 + Math.floor(Math.random() * 10000);
logger.info(`Category cooldown: waiting ${Math.round(catCooldownMs / 1000)}s before category ${catId}...`);
await new Promise(resolve => setTimeout(resolve, catCooldownMs));
// Scrape category books
const books = await audibleService.getCategoryBooks(catId, 200);
logger.info(`Category ${catId}: fetched ${books.length} books`);
const saved = await persistSectionBooks(
books, catId, syncTime, thumbnailCache, logger, 'category book'
);
categoriesSynced++;
logger.info(`Category ${catId}: saved ${saved} entries`);
} catch (error) {
logger.error(`Failed to refresh category ${catId}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
} }
await prisma.audibleCache.upsert({ logger.info(`Category refresh complete: ${categoriesSynced}/${categoryIds.length} categories synced`);
where: { asin: audiobook.asin },
create: {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isPopular: true,
popularRank: i + 1,
lastSyncedAt: syncTime,
},
update: {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isPopular: true,
popularRank: i + 1,
lastSyncedAt: syncTime,
},
});
popularSaved++;
} catch (error) {
logger.error(`Failed to save popular audiobook ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
} catch (error) {
logger.error(`Category refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
for (let i = 0; i < newReleases.length; i++) {
const audiobook = newReleases[i];
try {
// Cache thumbnail if coverArtUrl exists
let cachedCoverPath: string | null = null;
if (audiobook.coverArtUrl) {
cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl);
}
await prisma.audibleCache.upsert({
where: { asin: audiobook.asin },
create: {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isNewRelease: true,
newReleaseRank: i + 1,
lastSyncedAt: syncTime,
},
update: {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isNewRelease: true,
newReleaseRank: i + 1,
lastSyncedAt: syncTime,
},
});
newReleasesSaved++;
} catch (error) {
logger.error(`Failed to save new release ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
logger.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases to audible_cache`);
// Cleanup unused thumbnails // Cleanup unused thumbnails
logger.info('Cleaning up unused thumbnails...'); logger.info('Cleaning up unused thumbnails...');
const allActiveAsins = await prisma.audibleCache.findMany({ const allActiveAsins = await prisma.audibleCache.findMany({
@@ -175,6 +116,7 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
message: 'Audible refresh completed', message: 'Audible refresh completed',
popularSaved, popularSaved,
newReleasesSaved, newReleasesSaved,
categoriesSynced,
thumbnailsDeleted: deletedCount, thumbnailsDeleted: deletedCount,
}; };
} catch (error) { } catch (error) {
@@ -182,3 +124,87 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
throw error; throw error;
} }
} }
/**
* Wipe previous entries for a category, upsert book metadata into AudibleCache,
* and insert ranked entries into AudibleCacheCategory.
* Returns the number of books successfully saved.
*/
async function persistSectionBooks(
books: any[],
categoryId: string,
syncTime: Date,
thumbnailCache: { cacheThumbnail: (asin: string, url: string) => Promise<string | null> },
logger: ReturnType<typeof RMABLogger.forJob>,
labelForErrors: string,
): Promise<number> {
// Wipe previous entries for this section
logger.info(`Clearing previous data for ${categoryId}...`);
await prisma.audibleCacheCategory.deleteMany({
where: { categoryId },
});
logger.info(`Cleared previous entries for ${categoryId}, saving ${books.length} books...`);
let saved = 0;
for (let i = 0; i < books.length; i++) {
const book = books[i];
try {
// Cache thumbnail if coverArtUrl exists
let cachedCoverPath: string | null = null;
if (book.coverArtUrl) {
cachedCoverPath = await thumbnailCache.cacheThumbnail(book.asin, book.coverArtUrl);
if (!cachedCoverPath) {
logger.warn(`Cover cache failed for "${book.title}" (${book.asin}) - falling back to remote URL`);
}
}
// Upsert book metadata into AudibleCache
await prisma.audibleCache.upsert({
where: { asin: book.asin },
create: {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator,
description: book.description,
coverArtUrl: book.coverArtUrl,
cachedCoverPath,
durationMinutes: book.durationMinutes,
releaseDate: book.releaseDate ? new Date(book.releaseDate) : null,
rating: book.rating ? book.rating : null,
genres: book.genres || [],
lastSyncedAt: syncTime,
},
update: {
title: book.title,
author: book.author,
narrator: book.narrator,
description: book.description,
coverArtUrl: book.coverArtUrl,
cachedCoverPath,
durationMinutes: book.durationMinutes,
releaseDate: book.releaseDate ? new Date(book.releaseDate) : null,
rating: book.rating ? book.rating : null,
genres: book.genres || [],
lastSyncedAt: syncTime,
},
});
// Insert ranked entry into AudibleCacheCategory
await prisma.audibleCacheCategory.create({
data: {
asin: book.asin,
categoryId,
rank: i + 1,
lastSyncedAt: syncTime,
},
});
saved++;
} catch (error) {
logger.error(`Failed to save ${labelForErrors} ${book.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
return saved;
}
+9 -13
View File
@@ -24,7 +24,7 @@ export class ThumbnailCacheService {
try { try {
await fs.mkdir(CACHE_DIR, { recursive: true }); await fs.mkdir(CACHE_DIR, { recursive: true });
} catch (error) { } catch (error) {
logger.error('Failed to create cache directory', { error: error instanceof Error ? error.message : String(error) }); logger.error(`Failed to create cache directory: ${error instanceof Error ? error.message : String(error)}`);
throw error; throw error;
} }
} }
@@ -36,7 +36,7 @@ export class ThumbnailCacheService {
try { try {
await fs.mkdir(LIBRARY_CACHE_DIR, { recursive: true }); await fs.mkdir(LIBRARY_CACHE_DIR, { recursive: true });
} catch (error) { } catch (error) {
logger.error('Failed to create library cache directory', { error: error instanceof Error ? error.message : String(error) }); logger.error(`Failed to create library cache directory: ${error instanceof Error ? error.message : String(error)}`);
throw error; throw error;
} }
} }
@@ -127,8 +127,8 @@ export class ThumbnailCacheService {
logger.info(`Cached thumbnail for ${asin}: ${filePath}`); logger.info(`Cached thumbnail for ${asin}: ${filePath}`);
return filePath; return filePath;
} catch (error) { } catch (error) {
// Log error but don't throw - we'll fall back to the original URL // Log warning but don't throw - we'll fall back to the original URL
logger.error(`Failed to cache thumbnail for ${asin}`, { error: error instanceof Error ? error.message : String(error) }); logger.warn(`Failed to cache thumbnail for ${asin}: ${error instanceof Error ? error.message : String(error)} - will use remote URL`);
return null; return null;
} }
} }
@@ -203,10 +203,8 @@ export class ThumbnailCacheService {
logger.info(`Cached library thumbnail for ${plexGuid}: ${filePath}`); logger.info(`Cached library thumbnail for ${plexGuid}: ${filePath}`);
return filePath; return filePath;
} catch (error) { } catch (error) {
// Log error but don't throw - graceful degradation // Log warning but don't throw - graceful degradation
logger.warn(`Failed to cache library thumbnail for ${plexGuid}`, { logger.warn(`Failed to cache library thumbnail for ${plexGuid}: ${error instanceof Error ? error.message : String(error)}`);
error: error instanceof Error ? error.message : String(error),
});
return null; return null;
} }
} }
@@ -227,7 +225,7 @@ export class ThumbnailCacheService {
logger.info(`Deleted thumbnail: ${filePath}`); logger.info(`Deleted thumbnail: ${filePath}`);
} }
} catch (error) { } catch (error) {
logger.error(`Failed to delete thumbnail for ${asin}`, { error: error instanceof Error ? error.message : String(error) }); logger.error(`Failed to delete thumbnail for ${asin}: ${error instanceof Error ? error.message : String(error)}`);
} }
} }
@@ -258,7 +256,7 @@ export class ThumbnailCacheService {
logger.info(`Cleanup complete: ${deletedCount} thumbnails deleted`); logger.info(`Cleanup complete: ${deletedCount} thumbnails deleted`);
return deletedCount; return deletedCount;
} catch (error) { } catch (error) {
logger.error('Failed to cleanup thumbnails', { error: error instanceof Error ? error.message : String(error) }); logger.error(`Failed to cleanup thumbnails: ${error instanceof Error ? error.message : String(error)}`);
return 0; return 0;
} }
} }
@@ -299,9 +297,7 @@ export class ThumbnailCacheService {
logger.info(`Library cleanup complete: ${deletedCount} thumbnails deleted`); logger.info(`Library cleanup complete: ${deletedCount} thumbnails deleted`);
return deletedCount; return deletedCount;
} catch (error) { } catch (error) {
logger.error('Failed to cleanup library thumbnails', { logger.error(`Failed to cleanup library thumbnails: ${error instanceof Error ? error.message : String(error)}`);
error: error instanceof Error ? error.message : String(error),
});
return 0; return 0;
} }
} }
+21 -7
View File
@@ -68,6 +68,12 @@ describe('Audiobooks browse routes', () => {
}); });
it('returns popular audiobooks with cached cover URLs', async () => { it('returns popular audiobooks with cached cover URLs', async () => {
// Mock AudibleCacheCategory query (popular route now queries category table)
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
{ asin: 'ASIN', rank: 1 },
]);
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(1);
// Mock AudibleCache metadata fetch
prismaMock.audibleCache.findMany.mockResolvedValueOnce([ prismaMock.audibleCache.findMany.mockResolvedValueOnce([
{ {
asin: 'ASIN', asin: 'ASIN',
@@ -84,7 +90,6 @@ describe('Audiobooks browse routes', () => {
lastSyncedAt: new Date(), lastSyncedAt: new Date(),
}, },
]); ]);
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', coverArtUrl: '/api/cache/thumbnails/asin.jpg' }]); enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', coverArtUrl: '/api/cache/thumbnails/asin.jpg' }]);
const { GET } = await import('@/app/api/audiobooks/popular/route'); const { GET } = await import('@/app/api/audiobooks/popular/route');
@@ -106,8 +111,9 @@ describe('Audiobooks browse routes', () => {
}); });
it('returns new release audiobooks', async () => { it('returns new release audiobooks', async () => {
prismaMock.audibleCache.findMany.mockResolvedValueOnce([]); // Mock AudibleCacheCategory query (new-releases route now queries category table)
prismaMock.audibleCache.count.mockResolvedValueOnce(0); prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([]);
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(0);
const { GET } = await import('@/app/api/audiobooks/new-releases/route'); const { GET } = await import('@/app/api/audiobooks/new-releases/route');
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any); const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any);
@@ -118,6 +124,12 @@ describe('Audiobooks browse routes', () => {
}); });
it('enriches new releases and uses cached cover URLs', async () => { it('enriches new releases and uses cached cover URLs', async () => {
// Mock AudibleCacheCategory query
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
{ asin: 'ASIN', rank: 1 },
]);
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(1);
// Mock AudibleCache metadata fetch
prismaMock.audibleCache.findMany.mockResolvedValueOnce([ prismaMock.audibleCache.findMany.mockResolvedValueOnce([
{ {
asin: 'ASIN', asin: 'ASIN',
@@ -134,7 +146,6 @@ describe('Audiobooks browse routes', () => {
lastSyncedAt: new Date('2024-01-02'), lastSyncedAt: new Date('2024-01-02'),
}, },
]); ]);
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
currentUserMock.mockReturnValue({ sub: 'user-1' }); currentUserMock.mockReturnValue({ sub: 'user-1' });
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', available: true }]); enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', available: true }]);
@@ -155,7 +166,7 @@ describe('Audiobooks browse routes', () => {
}); });
it('returns 500 when new releases query fails', async () => { it('returns 500 when new releases query fails', async () => {
prismaMock.audibleCache.findMany.mockRejectedValueOnce(new Error('db down')); prismaMock.audibleCacheCategory.findMany.mockRejectedValueOnce(new Error('db down'));
const { GET } = await import('@/app/api/audiobooks/new-releases/route'); const { GET } = await import('@/app/api/audiobooks/new-releases/route');
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any); const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any);
@@ -209,6 +220,11 @@ describe('Audiobooks browse routes', () => {
}); });
it('returns cached covers for login', async () => { it('returns cached covers for login', async () => {
// Mock AudibleCacheCategory query (covers route now queries category table)
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
{ asin: 'ASIN' },
]);
// Mock AudibleCache metadata fetch
prismaMock.audibleCache.findMany.mockResolvedValueOnce([ prismaMock.audibleCache.findMany.mockResolvedValueOnce([
{ asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null }, { asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null },
]); ]);
@@ -221,5 +237,3 @@ describe('Audiobooks browse routes', () => {
expect(payload.covers[0].coverUrl).toBe('/api/cache/thumbnails/asin.jpg'); expect(payload.covers[0].coverUrl).toBe('/api/cache/thumbnails/asin.jpg');
}); });
}); });
+166
View File
@@ -0,0 +1,166 @@
/**
* Component: Home Sections API Route Tests
* Documentation: documentation/features/home-sections.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: vi.fn((_req: any, handler: any) => {
const mockReq = {
user: { id: 'user-1', sub: 'user-1', role: 'user' },
json: async () => (globalThis as any).__testBody || {},
};
return handler(mockReq);
}),
getCurrentUser: vi.fn(() => ({ sub: 'user-1' })),
}));
vi.mock('@/lib/utils/logger', () => ({
RMABLogger: { create: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }) },
}));
describe('GET /api/user/home-sections', () => {
beforeEach(() => {
vi.clearAllMocks();
// Re-apply default mock implementations after clearAllMocks
prismaMock.userHomeSection.createMany.mockResolvedValue({ count: 0 });
prismaMock.userHomeSection.deleteMany.mockResolvedValue({ count: 0 });
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));
});
it('returns default sections for new user', async () => {
// ensureDefaultSections check: no existing sections
prismaMock.userHomeSection.findMany
.mockResolvedValueOnce([]) // ensureDefaultSections
.mockResolvedValueOnce([ // actual fetch after defaults created
{ id: '1', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 0 },
{ id: '2', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 1 },
]);
prismaMock.scheduledJob.findFirst.mockResolvedValueOnce(null);
const { GET } = await import('@/app/api/user/home-sections/route');
const request = new Request('http://localhost/api/user/home-sections');
const response = await GET(request as any);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.sections).toHaveLength(2);
expect(data.sections[0].sectionType).toBe('popular');
expect(data.sections[1].sectionType).toBe('new_releases');
});
it('returns existing sections without creating defaults', async () => {
prismaMock.userHomeSection.findMany
.mockResolvedValueOnce([{ id: '1' }]) // has existing
.mockResolvedValueOnce([
{ id: '1', sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 0 },
]);
prismaMock.scheduledJob.findFirst.mockResolvedValueOnce({
nextRun: new Date('2026-03-05T00:00:00Z'),
});
const { GET } = await import('@/app/api/user/home-sections/route');
const request = new Request('http://localhost/api/user/home-sections');
const response = await GET(request as any);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.sections).toHaveLength(1);
expect(data.sections[0].categoryName).toBe('Sci-Fi');
expect(data.nextRefresh).toBe('2026-03-05T00:00:00.000Z');
expect(prismaMock.userHomeSection.createMany).not.toHaveBeenCalled();
});
});
describe('PUT /api/user/home-sections', () => {
beforeEach(() => {
vi.clearAllMocks();
prismaMock.userHomeSection.createMany.mockResolvedValue({ count: 0 });
prismaMock.userHomeSection.deleteMany.mockResolvedValue({ count: 0 });
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));
});
it('saves new section configuration', async () => {
(globalThis as any).__testBody = {
sections: [
{ sectionType: 'new_releases', sortOrder: 0 },
{ sectionType: 'popular', sortOrder: 1 },
{ sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 2 },
],
};
prismaMock.userHomeSection.findMany.mockResolvedValueOnce([
{ id: '1', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 0 },
{ id: '2', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 1 },
{ id: '3', sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 2 },
]);
const { PUT } = await import('@/app/api/user/home-sections/route');
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
const response = await PUT(request as any);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.sections).toHaveLength(3);
expect(prismaMock.userHomeSection.deleteMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
});
expect(prismaMock.userHomeSection.createMany).toHaveBeenCalled();
});
it('rejects more than 10 sections', async () => {
(globalThis as any).__testBody = {
sections: Array.from({ length: 11 }, (_, i) => ({
sectionType: 'category',
categoryId: `cat-${i}`,
categoryName: `Cat ${i}`,
sortOrder: i,
})),
};
const { PUT } = await import('@/app/api/user/home-sections/route');
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
const response = await PUT(request as any);
expect(response.status).toBe(400);
});
it('rejects duplicate sections', async () => {
(globalThis as any).__testBody = {
sections: [
{ sectionType: 'popular', sortOrder: 0 },
{ sectionType: 'popular', sortOrder: 1 },
],
};
const { PUT } = await import('@/app/api/user/home-sections/route');
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
const response = await PUT(request as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.message).toContain('Duplicate');
});
it('rejects category section without categoryId', async () => {
(globalThis as any).__testBody = {
sections: [{ sectionType: 'category', sortOrder: 0 }],
};
const { PUT } = await import('@/app/api/user/home-sections/route');
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
const response = await PUT(request as any);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.message).toContain('categoryId');
});
});
+52 -14
View File
@@ -1,6 +1,6 @@
/** /**
* Component: Home Page Tests * Component: Home Page Tests
* Documentation: documentation/frontend/components.md * Documentation: documentation/features/home-sections.md
*/ */
// @vitest-environment jsdom // @vitest-environment jsdom
@@ -12,15 +12,26 @@ import { resetMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation'; import { resetMockRouter } from '../helpers/mock-next-navigation';
const useAudiobooksMock = vi.hoisted(() => vi.fn()); const useAudiobooksMock = vi.hoisted(() => vi.fn());
const useCategoryAudiobooksMock = vi.hoisted(() => vi.fn());
const useHomeSectionsMock = vi.hoisted(() => vi.fn());
const usePreferencesMock = vi.hoisted(() => ({ const usePreferencesMock = vi.hoisted(() => ({
cardSize: 5, cardSize: 5,
setCardSize: vi.fn(), setCardSize: vi.fn(),
squareCovers: false,
setSquareCovers: vi.fn(),
hideAvailable: false,
setHideAvailable: vi.fn(),
})); }));
vi.mock('@/lib/hooks/useAudiobooks', () => ({ vi.mock('@/lib/hooks/useAudiobooks', () => ({
useAudiobooks: useAudiobooksMock, useAudiobooks: useAudiobooksMock,
})); }));
vi.mock('@/lib/hooks/useHomeSections', () => ({
useHomeSections: useHomeSectionsMock,
useCategoryAudiobooks: useCategoryAudiobooksMock,
}));
vi.mock('@/contexts/PreferencesContext', () => ({ vi.mock('@/contexts/PreferencesContext', () => ({
usePreferences: () => usePreferencesMock, usePreferences: () => usePreferencesMock,
})); }));
@@ -71,9 +82,25 @@ describe('HomePage', () => {
resetMockAuthState(); resetMockAuthState();
resetMockRouter(); resetMockRouter();
useAudiobooksMock.mockReset(); useAudiobooksMock.mockReset();
useCategoryAudiobooksMock.mockReset();
useHomeSectionsMock.mockReset();
usePreferencesMock.cardSize = 5; usePreferencesMock.cardSize = 5;
usePreferencesMock.setCardSize.mockReset(); usePreferencesMock.setCardSize.mockReset();
usePreferencesMock.hideAvailable = false;
vi.resetModules(); vi.resetModules();
// Default: return popular + new_releases sections
useHomeSectionsMock.mockReturnValue({
sections: [
{ id: '1', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 0 },
{ id: '2', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 1 },
],
isLoading: false,
nextRefresh: null,
saveSections: vi.fn(),
mutate: vi.fn(),
error: null,
});
}); });
it('renders empty state messaging for popular audiobooks', async () => { it('renders empty state messaging for popular audiobooks', async () => {
@@ -97,28 +124,39 @@ describe('HomePage', () => {
const { default: HomePage } = await import('@/app/page'); const { default: HomePage } = await import('@/app/page');
render(<HomePage />); render(<HomePage />);
expect(screen.getByText('No popular audiobooks found')).toBeInTheDocument(); expect(screen.getByText('No audiobooks yet')).toBeInTheDocument();
expect(screen.getByText('Nothing here')).toBeInTheDocument(); // Raw API message is intentionally not shown; friendly empty state is rendered instead
expect(screen.queryByText('Nothing here')).not.toBeInTheDocument();
expect(screen.getByText('New Release')).toBeInTheDocument(); expect(screen.getByText('New Release')).toBeInTheDocument();
}); });
it('updates pagination when the sticky controls request a new page', async () => { it('renders customize button', async () => {
useAudiobooksMock.mockImplementation((category: string, _limit: number, page: number) => { useAudiobooksMock.mockReturnValue({
return { audiobooks: [],
audiobooks: [{ asin: `${category}-${page}`, title: `${category}-${page}`, author: 'Author' }], isLoading: false,
isLoading: false, totalPages: 0,
totalPages: 3, message: null,
message: null,
};
}); });
const { default: HomePage } = await import('@/app/page'); const { default: HomePage } = await import('@/app/page');
render(<HomePage />); render(<HomePage />);
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' })); expect(screen.getByLabelText('Customize home page')).toBeInTheDocument();
});
await waitFor(() => { it('renders empty state when no sections configured', async () => {
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined); useHomeSectionsMock.mockReturnValue({
sections: [],
isLoading: false,
nextRefresh: null,
saveSections: vi.fn(),
mutate: vi.fn(),
error: null,
}); });
const { default: HomePage } = await import('@/app/page');
render(<HomePage />);
expect(screen.getByText(/No sections configured/)).toBeInTheDocument();
}); });
}); });
+5
View File
@@ -10,6 +10,7 @@ type PrismaModelMock = {
findFirst: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>; upsert: ReturnType<typeof vi.fn>;
@@ -23,6 +24,7 @@ const createModelMock = (): PrismaModelMock => ({
findFirst: vi.fn(), findFirst: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
create: vi.fn(() => Promise.resolve({})), create: vi.fn(() => Promise.resolve({})),
createMany: vi.fn(() => Promise.resolve({ count: 0 })),
update: vi.fn(() => Promise.resolve({})), update: vi.fn(() => Promise.resolve({})),
updateMany: vi.fn(() => Promise.resolve({})), updateMany: vi.fn(() => Promise.resolve({})),
upsert: vi.fn(() => Promise.resolve({})), upsert: vi.fn(() => Promise.resolve({})),
@@ -52,6 +54,9 @@ export const createPrismaMock = () => ({
workAsin: createModelMock(), workAsin: createModelMock(),
watchedSeries: createModelMock(), watchedSeries: createModelMock(),
watchedAuthor: createModelMock(), watchedAuthor: createModelMock(),
userHomeSection: createModelMock(),
audibleCacheCategory: createModelMock(),
$queryRaw: vi.fn(), $queryRaw: vi.fn(),
$transaction: vi.fn(),
$disconnect: vi.fn(), $disconnect: vi.fn(),
}); });
@@ -10,6 +10,7 @@ const prismaMock = createPrismaMock();
const audibleServiceMock = vi.hoisted(() => ({ const audibleServiceMock = vi.hoisted(() => ({
getPopularAudiobooks: vi.fn(), getPopularAudiobooks: vi.fn(),
getNewReleases: vi.fn(), getNewReleases: vi.fn(),
getCategoryBooks: vi.fn(),
})); }));
const thumbnailCacheMock = vi.hoisted(() => ({ const thumbnailCacheMock = vi.hoisted(() => ({
cacheThumbnail: vi.fn(), cacheThumbnail: vi.fn(),
@@ -45,7 +46,7 @@ describe('processAudibleRefresh', () => {
global.setTimeout = origSetTimeout; global.setTimeout = origSetTimeout;
}); });
it('refreshes popular and new releases, caching thumbnails', async () => { it('refreshes popular and new releases via AudibleCacheCategory', async () => {
const popular = [ const popular = [
{ {
asin: 'ASIN-1', asin: 'ASIN-1',
@@ -91,8 +92,12 @@ describe('processAudibleRefresh', () => {
audibleServiceMock.getNewReleases.mockResolvedValue(newReleases); audibleServiceMock.getNewReleases.mockResolvedValue(newReleases);
thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/path.jpg'); thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/path.jpg');
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(2); thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(2);
prismaMock.audibleCache.updateMany.mockResolvedValue({ count: 1 });
prismaMock.audibleCache.upsert.mockResolvedValue({}); prismaMock.audibleCache.upsert.mockResolvedValue({});
prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 });
prismaMock.audibleCacheCategory.create.mockResolvedValue({});
// No user-configured categories
prismaMock.userHomeSection.findMany.mockResolvedValue([]);
prismaMock.audibleCache.findMany.mockResolvedValue([ prismaMock.audibleCache.findMany.mockResolvedValue([
{ asin: 'ASIN-1' }, { asin: 'ASIN-1' },
{ asin: 'ASIN-2' }, { asin: 'ASIN-2' },
@@ -105,8 +110,32 @@ describe('processAudibleRefresh', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.popularSaved).toBe(2); expect(result.popularSaved).toBe(2);
expect(result.newReleasesSaved).toBe(1); expect(result.newReleasesSaved).toBe(1);
expect(prismaMock.audibleCache.updateMany).toHaveBeenCalled(); expect(result.categoriesSynced).toBe(0);
// Should wipe old entries for __popular__ and __new_releases__
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
where: { categoryId: '__popular__' },
});
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
where: { categoryId: '__new_releases__' },
});
// 3 metadata upserts (2 popular + 1 new release)
expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(3); expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(3);
// 3 category entries created (2 popular + 1 new release)
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledTimes(3);
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({
data: expect.objectContaining({ asin: 'ASIN-1', categoryId: '__popular__', rank: 1 }),
});
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({
data: expect.objectContaining({ asin: 'ASIN-2', categoryId: '__popular__', rank: 2 }),
});
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({
data: expect.objectContaining({ asin: 'ASIN-3', categoryId: '__new_releases__', rank: 1 }),
});
// Thumbnail caching still works
expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-1', 'http://image/1'); expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-1', 'http://image/1');
expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-3', 'http://image/3'); expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-3', 'http://image/3');
expect(thumbnailCacheMock.cleanupUnusedThumbnails).toHaveBeenCalled(); expect(thumbnailCacheMock.cleanupUnusedThumbnails).toHaveBeenCalled();
@@ -115,8 +144,56 @@ describe('processAudibleRefresh', () => {
expect(Array.from(activeSet).sort()).toEqual(['ASIN-1', 'ASIN-2', 'ASIN-3']); expect(Array.from(activeSet).sort()).toEqual(['ASIN-1', 'ASIN-2', 'ASIN-3']);
}); });
it('scrapes user-configured categories after popular/new-releases', async () => {
audibleServiceMock.getPopularAudiobooks.mockResolvedValue([]);
audibleServiceMock.getNewReleases.mockResolvedValue([]);
thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/cat.jpg');
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(0);
prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 });
prismaMock.audibleCacheCategory.create.mockResolvedValue({});
// User has one category section
prismaMock.userHomeSection.findMany.mockResolvedValue([
{ categoryId: 'node-42' },
]);
// getCategoryBooks returns 2 books
audibleServiceMock.getCategoryBooks.mockResolvedValue([
{ asin: 'CAT-1', title: 'Cat Book 1', author: 'Author', coverArtUrl: 'http://img/c1' },
{ asin: 'CAT-2', title: 'Cat Book 2', author: 'Author', coverArtUrl: null },
]);
prismaMock.audibleCache.upsert.mockResolvedValue({});
prismaMock.audibleCache.findMany.mockResolvedValue([]);
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
const result = await processAudibleRefresh({ jobId: 'job-cat' });
expect(result.categoriesSynced).toBe(1);
expect(audibleServiceMock.getCategoryBooks).toHaveBeenCalledWith('node-42', 200);
// Should wipe entries for __popular__, __new_releases__, and node-42
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
where: { categoryId: '__popular__' },
});
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
where: { categoryId: '__new_releases__' },
});
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
where: { categoryId: 'node-42' },
});
// 2 category book creates (for node-42) — popular/new-releases had 0 books
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledTimes(2);
expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(2);
});
it('rethrows fatal errors', async () => { it('rethrows fatal errors', async () => {
prismaMock.audibleCache.updateMany.mockRejectedValue(new Error('DB down')); // Mock audible service to return data so we reach the DB calls
audibleServiceMock.getPopularAudiobooks.mockResolvedValue([]);
audibleServiceMock.getNewReleases.mockResolvedValue([]);
// First DB call is now audibleCacheCategory.deleteMany (for __popular__)
prismaMock.audibleCacheCategory.deleteMany.mockRejectedValue(new Error('DB down'));
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor'); const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down'); await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down');