mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add per-user home sections & unified Audible cache
Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 */}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Component: Audible Categories API Route
|
||||||
|
* Documentation: documentation/features/home-sections.md
|
||||||
|
*
|
||||||
|
* Live scrape of top-level Audible categories for the home section config modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Audible.Categories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audible/categories
|
||||||
|
* Returns top-level Audible categories scraped live from audible.com/categories
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (_req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const categories = await audibleService.getCategories();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
categories,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch categories', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch Audible categories' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Component: Category Audiobooks API Route
|
||||||
|
* Documentation: documentation/features/home-sections.md
|
||||||
|
*
|
||||||
|
* Serves audiobooks for a specific Audible category from AudibleCacheCategory,
|
||||||
|
* with the same enrichment pattern as popular/new-releases routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audiobooks/category/[categoryId]?page=1&limit=20&hideAvailable=false
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ categoryId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { categoryId } = await params;
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '20', 10);
|
||||||
|
const hideAvailable = searchParams.get('hideAvailable') === 'true';
|
||||||
|
|
||||||
|
if (page < 1 || limit < 1 || limit > 100) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Invalid pagination parameters.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Get excluded ASINs when hideAvailable
|
||||||
|
let excludedAsins: string[] = [];
|
||||||
|
if (hideAvailable) {
|
||||||
|
const availableSet = await getAvailableAsins();
|
||||||
|
excludedAsins = [...availableSet];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query AudibleCacheCategory joined with AudibleCache
|
||||||
|
const whereClause: any = { categoryId };
|
||||||
|
if (excludedAsins.length > 0) {
|
||||||
|
whereClause.asin = { notIn: excludedAsins };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [categoryEntries, totalCount] = await Promise.all([
|
||||||
|
prisma.audibleCacheCategory.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: { rank: 'asc' },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
select: { asin: true, rank: true },
|
||||||
|
}),
|
||||||
|
prisma.audibleCacheCategory.count({ where: whereClause }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (totalCount === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
audiobooks: [],
|
||||||
|
count: 0,
|
||||||
|
totalCount: 0,
|
||||||
|
page,
|
||||||
|
totalPages: 0,
|
||||||
|
hasMore: false,
|
||||||
|
message: 'No audiobooks found for this category. Data may not have been refreshed yet.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full metadata from AudibleCache for these ASINs
|
||||||
|
const asins = categoryEntries.map((e) => e.asin);
|
||||||
|
const cacheEntries = await prisma.audibleCache.findMany({
|
||||||
|
where: { asin: { in: asins } },
|
||||||
|
select: {
|
||||||
|
asin: true,
|
||||||
|
title: true,
|
||||||
|
author: true,
|
||||||
|
narrator: true,
|
||||||
|
description: true,
|
||||||
|
coverArtUrl: true,
|
||||||
|
cachedCoverPath: true,
|
||||||
|
durationMinutes: true,
|
||||||
|
releaseDate: true,
|
||||||
|
rating: true,
|
||||||
|
genres: true,
|
||||||
|
lastSyncedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a map for ordering by rank
|
||||||
|
const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e]));
|
||||||
|
|
||||||
|
// Transform to matcher input format, preserving rank order
|
||||||
|
const audibleBooks = categoryEntries
|
||||||
|
.map((entry) => {
|
||||||
|
const book = cacheMap.get(entry.asin);
|
||||||
|
if (!book) return null;
|
||||||
|
|
||||||
|
let coverUrl = book.coverArtUrl || undefined;
|
||||||
|
if (book.cachedCoverPath) {
|
||||||
|
const filename = book.cachedCoverPath.split('/').pop();
|
||||||
|
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
asin: book.asin,
|
||||||
|
title: book.title,
|
||||||
|
author: book.author,
|
||||||
|
narrator: book.narrator || undefined,
|
||||||
|
description: book.description || undefined,
|
||||||
|
coverArtUrl: coverUrl,
|
||||||
|
durationMinutes: book.durationMinutes || undefined,
|
||||||
|
releaseDate: book.releaseDate?.toISOString() || undefined,
|
||||||
|
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
|
||||||
|
genres: (book.genres as string[]) || [],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean) as any[];
|
||||||
|
|
||||||
|
// Enrich with library matching and request status
|
||||||
|
const currentUser = getCurrentUser(request);
|
||||||
|
const userId = currentUser?.sub || undefined;
|
||||||
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
audiobooks: enrichedAudiobooks,
|
||||||
|
count: enrichedAudiobooks.length,
|
||||||
|
totalCount,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
hasMore,
|
||||||
|
lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get category audiobooks', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch category audiobooks' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
* Component: Audiobook Covers API Route
|
* 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,
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Component: User Home Sections API Route
|
||||||
|
* Documentation: documentation/features/home-sections.md
|
||||||
|
*
|
||||||
|
* Per-user configurable home page sections.
|
||||||
|
* GET returns sections + next refresh time.
|
||||||
|
* PUT saves full section config (delete-and-recreate in transaction).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.User.HomeSections');
|
||||||
|
|
||||||
|
const MAX_SECTIONS = 10;
|
||||||
|
|
||||||
|
const VALID_SECTION_TYPES = ['popular', 'new_releases', 'category'] as const;
|
||||||
|
|
||||||
|
const SectionSchema = z.object({
|
||||||
|
sectionType: z.enum(VALID_SECTION_TYPES),
|
||||||
|
categoryId: z.string().optional().nullable(),
|
||||||
|
categoryName: z.string().optional().nullable(),
|
||||||
|
sortOrder: z.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PutBodySchema = z.object({
|
||||||
|
sections: z.array(SectionSchema).max(MAX_SECTIONS),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default home sections for a new user (Popular + New Releases).
|
||||||
|
*/
|
||||||
|
async function ensureDefaultSections(userId: string) {
|
||||||
|
const existing = await prisma.userHomeSection.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
await prisma.userHomeSection.createMany({
|
||||||
|
data: [
|
||||||
|
{ userId, sectionType: 'popular', sortOrder: 0 },
|
||||||
|
{ userId, sectionType: 'new_releases', sortOrder: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/home-sections
|
||||||
|
* Returns the user's configured home sections + next scheduled refresh time.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureDefaultSections(req.user.id);
|
||||||
|
|
||||||
|
const sections = await prisma.userHomeSection.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get next refresh time from scheduled jobs
|
||||||
|
let nextRefresh: string | null = null;
|
||||||
|
try {
|
||||||
|
const scheduledJob = await prisma.scheduledJob.findFirst({
|
||||||
|
where: { type: 'audible_refresh', enabled: true },
|
||||||
|
select: { nextRun: true },
|
||||||
|
});
|
||||||
|
nextRefresh = scheduledJob?.nextRun?.toISOString() || null;
|
||||||
|
} catch {
|
||||||
|
// Non-critical — just omit nextRefresh
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sections: sections.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
sectionType: s.sectionType,
|
||||||
|
categoryId: s.categoryId,
|
||||||
|
categoryName: s.categoryName,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
})),
|
||||||
|
nextRefresh,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get home sections', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch home sections' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/user/home-sections
|
||||||
|
* Replaces all home sections for the user (delete-and-recreate in transaction).
|
||||||
|
* Validates: max 10 sections, no duplicate sections, category sections need categoryId.
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { sections } = PutBodySchema.parse(body);
|
||||||
|
|
||||||
|
// Validate category sections have categoryId
|
||||||
|
for (const section of sections) {
|
||||||
|
if (section.sectionType === 'category' && !section.categoryId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: 'Category sections require a categoryId' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate section types (only one popular, one new_releases, unique categories)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const section of sections) {
|
||||||
|
const key =
|
||||||
|
section.sectionType === 'category'
|
||||||
|
? `category:${section.categoryId}`
|
||||||
|
: section.sectionType;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', message: `Duplicate section: ${key}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Delete-and-recreate in a transaction
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userHomeSection.deleteMany({ where: { userId } });
|
||||||
|
|
||||||
|
if (sections.length > 0) {
|
||||||
|
await tx.userHomeSection.createMany({
|
||||||
|
data: sections.map((s, idx) => ({
|
||||||
|
userId,
|
||||||
|
sectionType: s.sectionType,
|
||||||
|
categoryId: s.sectionType === 'category' ? s.categoryId : null,
|
||||||
|
categoryName: s.sectionType === 'category' ? s.categoryName : null,
|
||||||
|
sortOrder: idx,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the saved sections
|
||||||
|
const saved = await prisma.userHomeSection.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User ${userId} updated home sections (${saved.length} sections)`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sections: saved.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
sectionType: s.sectionType,
|
||||||
|
categoryId: s.categoryId,
|
||||||
|
categoryName: s.categoryName,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save home sections', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SaveError', message: 'Failed to save home sections' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -486,6 +486,7 @@ function LoginContent() {
|
|||||||
quality={70}
|
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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user