From 41d45d12101ed63515261ab16e6dc258bbcdbc88 Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Fri, 27 Feb 2026 15:46:10 -0700 Subject: [PATCH] Refactor shelves UI and jobs --- src/app/api/user/goodreads-shelves/route.ts | 104 +++-- src/app/api/user/hardcover-shelves/route.ts | 2 +- src/app/api/user/shelves/route.ts | 99 +++++ src/app/profile/page.tsx | 10 +- .../profile/GoodreadsShelvesSection.tsx | 360 ------------------ ...rShelvesSection.tsx => ShelvesSection.tsx} | 195 +++++++--- src/lib/hooks/useShelves.ts | 40 ++ .../sync-goodreads-shelves.processor.ts | 42 -- .../sync-hardcover-shelves.processor.ts | 46 --- src/lib/processors/sync-shelves.processor.ts | 96 +++++ src/lib/services/job-queue.service.ts | 69 +--- src/lib/services/scheduler.service.ts | 67 ++-- 12 files changed, 511 insertions(+), 619 deletions(-) create mode 100644 src/app/api/user/shelves/route.ts delete mode 100644 src/components/profile/GoodreadsShelvesSection.tsx rename src/components/profile/{HardcoverShelvesSection.tsx => ShelvesSection.tsx} (69%) create mode 100644 src/lib/hooks/useShelves.ts delete mode 100644 src/lib/processors/sync-goodreads-shelves.processor.ts delete mode 100644 src/lib/processors/sync-hardcover-shelves.processor.ts create mode 100644 src/lib/processors/sync-shelves.processor.ts diff --git a/src/app/api/user/goodreads-shelves/route.ts b/src/app/api/user/goodreads-shelves/route.ts index 9736619..b598349 100644 --- a/src/app/api/user/goodreads-shelves/route.ts +++ b/src/app/api/user/goodreads-shelves/route.ts @@ -16,10 +16,13 @@ const logger = RMABLogger.create('API.GoodreadsShelves'); const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//; const AddShelfSchema = z.object({ - rssUrl: z.string().url().refine( - (url) => GOODREADS_RSS_PATTERN.test(url), - { message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' } - ), + rssUrl: z + .string() + .url() + .refine((url) => GOODREADS_RSS_PATTERN.test(url), { + message: + 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)', + }), }); /** @@ -40,7 +43,12 @@ export async function GET(request: NextRequest) { const shelvesWithMeta = shelves.map((shelf) => { // Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[]) - let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = []; + let books: { + coverUrl: string; + asin: string | null; + title: string; + author: string; + }[] = []; if (shelf.coverUrls) { const parsed = JSON.parse(shelf.coverUrls); if (Array.isArray(parsed)) { @@ -72,8 +80,13 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: true, shelves: shelvesWithMeta }); } catch (error) { - logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) }); - return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 }); + logger.error('Failed to list shelves', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to list shelves' }, + { status: 500 }, + ); } }); } @@ -99,30 +112,43 @@ export async function POST(request: NextRequest) { if (existing) { return NextResponse.json( - { error: 'DuplicateShelf', message: 'You have already added this shelf' }, - { status: 409 } + { + error: 'DuplicateShelf', + message: 'You have already added this shelf', + }, + { status: 409 }, ); } // Validate by fetching the RSS feed let shelfName: string; let bookCount: number; - let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = []; + let initialBooks: { + coverUrl: string; + asin: null; + title: string; + author: string; + }[] = []; try { const rssData = await fetchAndValidateRss(rssUrl); shelfName = rssData.shelfName; bookCount = rssData.books.length; initialBooks = rssData.books - .filter(b => b.coverUrl) + .filter((b) => b.coverUrl) .slice(0, 8) - .map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author })); + .map((b) => ({ + coverUrl: b.coverUrl!, + asin: null, + title: b.title, + author: b.author, + })); } catch (error) { return NextResponse.json( { error: 'InvalidRSS', message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`, }, - { status: 400 } + { status: 400 }, ); } @@ -132,43 +158,55 @@ export async function POST(request: NextRequest) { name: shelfName, rssUrl, bookCount, - coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, + coverUrls: + initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, }, }); - // Trigger immediate sync for this shelf (unlimited lookups, process all books) try { const jobQueue = getJobQueueService(); - await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0); - logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`); + await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0); + logger.info( + `Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`, + ); } catch (error) { - logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Failed to trigger immediate shelf sync', { + error: error instanceof Error ? error.message : String(error), + }); } - return NextResponse.json({ - success: true, - shelf: { - id: shelf.id, - name: shelf.name, - rssUrl: shelf.rssUrl, - lastSyncAt: shelf.lastSyncAt, - createdAt: shelf.createdAt, - bookCount: shelf.bookCount, - books: initialBooks, + return NextResponse.json( + { + success: true, + shelf: { + id: shelf.id, + name: shelf.name, + rssUrl: shelf.rssUrl, + lastSyncAt: shelf.lastSyncAt, + createdAt: shelf.createdAt, + bookCount: shelf.bookCount, + books: initialBooks, + }, + bookCount, }, - bookCount, - }, { status: 201 }); + { status: 201 }, + ); } catch (error) { - logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Failed to add shelf', { + error: error instanceof Error ? error.message : String(error), + }); if (error instanceof z.ZodError) { return NextResponse.json( { error: 'ValidationError', details: error.errors }, - { status: 400 } + { status: 400 }, ); } - return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 }); + return NextResponse.json( + { error: 'Failed to add shelf' }, + { status: 500 }, + ); } }); } diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index c1489b3..7e07213 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -159,7 +159,7 @@ export async function POST(request: NextRequest) { // Trigger immediate sync for this shelf (unlimited lookups, process all books) try { const jobQueue = getJobQueueService(); - await jobQueue.addSyncHardcoverShelvesJob(undefined, shelf.id, 0); + await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0); logger.info( `Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`, ); diff --git a/src/app/api/user/shelves/route.ts b/src/app/api/user/shelves/route.ts new file mode 100644 index 0000000..93419df --- /dev/null +++ b/src/app/api/user/shelves/route.ts @@ -0,0 +1,99 @@ +/** + * Component: Combined Shelves API Routes + * Documentation: documentation/backend/services/goodreads-sync.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Shelves'); + +/** + * GET /api/user/shelves + * List the current user's shelves (Goodreads, Hardcover) with book counts and covers + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const [goodreads, hardcover] = await Promise.all([ + prisma.goodreadsShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }), + prisma.hardcoverShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + const processBooks = (coverUrls: string | null) => { + let books: { + coverUrl: string; + asin: string | null; + title: string; + author: string; + }[] = []; + if (coverUrls) { + const parsed = JSON.parse(coverUrls); + if (Array.isArray(parsed)) { + books = parsed.map((item: unknown) => { + if (typeof item === 'string') { + return { coverUrl: item, asin: null, title: '', author: '' }; + } + const obj = item as Record; + return { + coverUrl: (obj.coverUrl as string) || '', + asin: (obj.asin as string) || null, + title: (obj.title as string) || '', + author: (obj.author as string) || '', + }; + }); + } + } + return books; + }; + + const combined = [ + ...goodreads.map((s) => ({ + id: s.id, + type: 'goodreads', + name: s.name, + sourceId: s.rssUrl, + lastSyncAt: s.lastSyncAt, + createdAt: s.createdAt, + bookCount: s.bookCount ?? null, + books: processBooks(s.coverUrls), + })), + ...hardcover.map((s) => ({ + id: s.id, + type: 'hardcover', + name: s.name, + sourceId: s.listId, + lastSyncAt: s.lastSyncAt, + createdAt: s.createdAt, + bookCount: s.bookCount ?? null, + books: processBooks(s.coverUrls), + })), + ].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return NextResponse.json({ success: true, shelves: combined }); + } catch (error) { + logger.error('Failed to list shelves', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to list shelves' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 19ad798..6fcd163 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -11,8 +11,7 @@ import { RequestCard } from '@/components/requests/RequestCard'; import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; -import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; -import { HardcoverShelvesSection } from '@/components/profile/HardcoverShelvesSection'; +import { ShelvesSection } from '@/components/profile/ShelvesSection'; const statConfig = [ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, @@ -179,11 +178,8 @@ export default function ProfilePage() { - {/* Goodreads Shelves */} - - - {/* Hardcover Lists */} - + {/* Generic Shelves Section */} + {/* Active Downloads */} {activeDownloads.length > 0 && ( diff --git a/src/components/profile/GoodreadsShelvesSection.tsx b/src/components/profile/GoodreadsShelvesSection.tsx deleted file mode 100644 index 0b8d5e5..0000000 --- a/src/components/profile/GoodreadsShelvesSection.tsx +++ /dev/null @@ -1,360 +0,0 @@ -/** - * Component: Goodreads Shelves Section (Profile Page) - * Documentation: documentation/frontend/components.md - */ - -'use client'; - -import React, { useState } from 'react'; -import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; -import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; -import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; -import { usePreferences } from '@/contexts/PreferencesContext'; -import { cn } from '@/lib/utils/cn'; - -function formatRelativeTime(dateStr: string | null): string { - if (!dateStr) return 'Never'; - const date = new Date(dateStr); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; -} - -export function GoodreadsShelvesSection() { - const { shelves, isLoading } = useGoodreadsShelves(); - const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf(); - const { squareCovers } = usePreferences(); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [showAddModal, setShowAddModal] = useState(false); - const [selectedAsin, setSelectedAsin] = useState(null); - - const handleDelete = async (shelfId: string) => { - try { - await deleteShelf(shelfId); - setConfirmDeleteId(null); - } catch { - // Error handled by hook - } - }; - - return ( -
- {/* Section Header */} -
-
-
- - - -
-
-

- Goodreads Shelves -

- {!isLoading && shelves.length > 0 && ( -

- {shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected -

- )} -
-
- - -
- - {/* Content */} - {isLoading ? ( - - ) : shelves.length > 0 ? ( -
- {shelves.map((shelf) => ( - handleDelete(shelf.id)} - onConfirmDelete={() => setConfirmDeleteId(shelf.id)} - onCancelDelete={() => setConfirmDeleteId(null)} - onBookClick={(asin) => setSelectedAsin(asin)} - /> - ))} -
- ) : ( - setShowAddModal(true)} /> - )} - - setShowAddModal(false)} - /> - - {/* Audiobook Detail Modal (read-only) */} - {selectedAsin && ( - setSelectedAsin(null)} - hideRequestActions - /> - )} -
- ); -} - -/* ─── Empty State ─── */ - -function EmptyState({ onAdd }: { onAdd: () => void }) { - return ( -
-
- - - -
- -

- Connect your reading list -

-

- Link a Goodreads shelf and we'll automatically request the audiobook for every book you add. -

- - -
- ); -} - -/* ─── Loading Skeleton ─── */ - -function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { - return ( -
-
-
-
-
-
-
-
-
- {[...Array(5)].map((_, i) => ( -
0 ? '-16px' : 0, zIndex: 5 - i }} - /> - ))} -
-
- ); -} - -/* ─── Shelf Card ─── */ - -interface ShelfCardProps { - shelf: GoodreadsShelf; - squareCovers: boolean; - isDeleting: boolean; - isConfirmingDelete: boolean; - onDelete: () => void; - onConfirmDelete: () => void; - onCancelDelete: () => void; - onBookClick: (asin: string) => void; -} - -function ShelfCard({ - shelf, - squareCovers, - isDeleting, - isConfirmingDelete, - onDelete, - onConfirmDelete, - onCancelDelete, - onBookClick, -}: ShelfCardProps) { - const displayBooks = shelf.books.slice(0, 6); - const hasCovers = displayBooks.length > 0; - const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length); - const isSyncing = !shelf.lastSyncAt; - - return ( -
- {/* Top: Shelf info + actions */} -
-
-

- {shelf.name} -

-
- {shelf.bookCount != null && ( - - {shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'} - - )} - - {isSyncing ? ( - <> - - - - - Syncing… - - ) : shelf.lastSyncAt ? ( - <> - - Synced {formatRelativeTime(shelf.lastSyncAt)} - - ) : ( - 'Pending sync' - )} - -
-
- - {/* Delete action */} -
- {isConfirmingDelete ? ( -
- - -
- ) : ( - - )} -
-
- - {/* Bottom: Stacked book covers */} - {hasCovers ? ( - - ) : isSyncing ? ( -
- {[...Array(3)].map((_, i) => ( -
0 ? '-16px' : 0, zIndex: 3 - i }} - /> - ))} -
- ) : null} -
- ); -} - -/* ─── Stacked Cover Display ─── */ - -function CoverStack({ - books, - remainingCount, - squareCovers, - onBookClick, -}: { - books: ShelfBook[]; - remainingCount: number; - squareCovers: boolean; - onBookClick: (asin: string) => void; -}) { - const [hoveredIndex, setHoveredIndex] = useState(null); - const coverSize = squareCovers - ? 'w-[80px] aspect-square' - : 'w-[72px] aspect-[2/3]'; - - return ( -
- {books.map((book, i) => ( -
0 ? '-16px' : 0, - zIndex: hoveredIndex === i ? 50 : books.length - i, - }} - onMouseEnter={() => setHoveredIndex(i)} - onMouseLeave={() => setHoveredIndex(null)} - onClick={() => book.asin && onBookClick(book.asin)} - title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined} - > - -
- ))} - {remainingCount > 0 && ( -
- - +{remainingCount} - -
- )} -
- ); -} diff --git a/src/components/profile/HardcoverShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx similarity index 69% rename from src/components/profile/HardcoverShelvesSection.tsx rename to src/components/profile/ShelvesSection.tsx index 67abc15..a84ec11 100644 --- a/src/components/profile/HardcoverShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -1,21 +1,21 @@ /** - * Component: Hardcover Shelves Section (Profile Page) + * Component: Combined Shelves Section (Profile Page) * Documentation: documentation/frontend/components.md */ 'use client'; import React, { useState } from 'react'; -import { - useHardcoverShelves, - useDeleteHardcoverShelf, - HardcoverShelf, - ShelfBook, -} from '@/lib/hooks/useHardcoverShelves'; +import { useShelves, GenericShelf } from '@/lib/hooks/useShelves'; +import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; import { AddHardcoverShelfModal } from '@/components/ui/AddHardcoverShelfModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { usePreferences } from '@/contexts/PreferencesContext'; import { cn } from '@/lib/utils/cn'; +import { Modal } from '@/components/ui/Modal'; +import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; function formatRelativeTime(dateStr: string | null): string { if (!dateStr) return 'Never'; @@ -31,31 +31,43 @@ function formatRelativeTime(dateStr: string | null): string { return `${diffDays}d ago`; } -export function HardcoverShelvesSection() { - const { shelves, isLoading } = useHardcoverShelves(); - const { deleteShelf, isLoading: isDeleting } = useDeleteHardcoverShelf(); +export function ShelvesSection() { + const { shelves, isLoading } = useShelves(); + const { deleteShelf: deleteGoodreads, isLoading: isDeletingGoodreads } = + useDeleteGoodreadsShelf(); + const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } = + useDeleteHardcoverShelf(); const { squareCovers } = usePreferences(); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [showAddModal, setShowAddModal] = useState(false); + const [showProviderSelect, setShowProviderSelect] = useState(false); + const [showAddGoodreads, setShowAddGoodreads] = useState(false); + const [showAddHardcover, setShowAddHardcover] = useState(false); const [selectedAsin, setSelectedAsin] = useState(null); - const handleDelete = async (shelfId: string) => { + const handleDelete = async (shelf: GenericShelf) => { try { - await deleteShelf(shelfId); + if (shelf.type === 'goodreads') { + await deleteGoodreads(shelf.id); + } else { + await deleteHardcover(shelf.id); + } setConfirmDeleteId(null); } catch { // Error handled by hook } }; + const isDeleting = isDeletingGoodreads || isDeletingHardcover; + return (
{/* Section Header */}
-
+

- Hardcover Lists + Shelves

{!isLoading && shelves.length > 0 && (

- {shelves.length} {shelves.length === 1 ? 'list' : 'lists'}{' '} + {shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'}{' '} connected

)}
- + + + + Add Shelf + + )}
{/* Content */} @@ -114,7 +128,7 @@ export function HardcoverShelvesSection() { squareCovers={squareCovers} isDeleting={isDeleting && confirmDeleteId === shelf.id} isConfirmingDelete={confirmDeleteId === shelf.id} - onDelete={() => handleDelete(shelf.id)} + onDelete={() => handleDelete(shelf)} onConfirmDelete={() => setConfirmDeleteId(shelf.id)} onCancelDelete={() => setConfirmDeleteId(null)} onBookClick={(asin) => setSelectedAsin(asin)} @@ -122,15 +136,30 @@ export function HardcoverShelvesSection() { ))}
) : ( - setShowAddModal(true)} /> + setShowProviderSelect(true)} /> )} - setShowAddModal(false)} + {/* Modals */} + setShowProviderSelect(false)} + onSelect={(provider) => { + setShowProviderSelect(false); + if (provider === 'goodreads') setShowAddGoodreads(true); + else if (provider === 'hardcover') setShowAddHardcover(true); + }} + /> + + setShowAddGoodreads(false)} + /> + + setShowAddHardcover(false)} /> - {/* Audiobook Detail Modal (read-only) */} {selectedAsin && ( void; + onSelect: (provider: string) => void; +}) { + return ( + +
+ + +
+
+ ); +} + /* ─── Empty State ─── */ function EmptyState({ onAdd }: { onAdd: () => void }) { return (
-
+
void }) { Connect your reading list

- Link a Hardcover list and we'll automatically request the audiobook for - every book you add. + Link a Goodreads or Hardcover shelf and we'll automatically request the + audiobook for every book you add.

); @@ -226,7 +310,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { /* ─── Shelf Card ─── */ interface ShelfCardProps { - shelf: HardcoverShelf; + shelf: GenericShelf; squareCovers: boolean; isDeleting: boolean; isConfirmingDelete: boolean; @@ -254,6 +338,17 @@ function ShelfCard({ ); const isSyncing = !shelf.lastSyncAt; + const providerIcon = + shelf.type === 'goodreads' ? ( + + g + + ) : ( + + H + + ); + return (
{/* Top: Shelf info + actions */} @@ -264,8 +359,8 @@ function ShelfCard({ )} >
-

- {shelf.name} +

+ {shelf.name} {providerIcon}

{shelf.bookCount != null && ( @@ -317,7 +412,7 @@ function ShelfCard({