From c57d0c1492dfba50530d3341d99c39ee92fa576f Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 13:16:23 -0700 Subject: [PATCH] Add a manage shelf modal --- public/goodreads-icon.png | Bin 0 -> 1062 bytes public/hardcover-icon.svg | 1 + .../api/user/goodreads-shelves/[id]/route.ts | 60 ++++++++ .../api/user/hardcover-shelves/[id]/route.ts | 89 ++++++++++++ src/components/profile/ShelvesSection.tsx | 84 +++++++---- src/components/ui/AddShelfModal.tsx | 36 ++--- src/components/ui/ManageShelfModal.tsx | 136 ++++++++++++++++++ src/lib/hooks/useGoodreadsShelves.ts | 50 +++++++ src/lib/hooks/useHardcoverShelves.ts | 53 +++++++ 9 files changed, 459 insertions(+), 50 deletions(-) create mode 100644 public/goodreads-icon.png create mode 100644 public/hardcover-icon.svg create mode 100644 src/components/ui/ManageShelfModal.tsx diff --git a/public/goodreads-icon.png b/public/goodreads-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cbfa21c57e56f3016fea26494b3db5667d1ca70c GIT binary patch literal 1062 zcmV+>1ljwEP)vu=KRn7-*eBoQ1xf?=U+O+9HT?d zkpRCrK|YerskkK@l8O!P@r3P2B}ckGM=-!4GM>y`C@mc|(0Iaq-+j#bQwpaLe#@Fc>gw5ex?Ry(jVPrSZC* z_O?rG`v6Y_An4@WTS_l1me>Lyv#l-7Mu5nEdSPkb>$m%E(iL5R3{|QpadK{+63I7B;V*~b z^2(}SHouW7QUZ{D5d@FSmjl8^?%lPXhwndhwRg(F7631cl_naA{5N>}d(9Pst&$El z8oaT1Fe*R&$Z!WK{?oSI>#eVjVZKp;r6umy-2*O>klOE5NdO;o;>U<(jC#{5=+ z+~U;ip$MqJ768k{ni{R=bY{`j@4@>{2Vi^KMN&a(6ZiM^SfvxKv71^xp*MbF_Av)n zd<6hj!G3u44-6i-5||ax#G&^QKLc}sDoXRfklxE=7WM+i;DW58zel#$vpLFqSSl%- z*tlq=RptN_B?0jZmmMdta=X74%;00_8p&WL0_wLJb=G`05EWw3cg;w-xVhkJ{>)Gs;U?K5-$P3s45S*Q^>%HU_h=`qOq_}=ZbgDt^05jqD zLM{B;GZU-|PHu=EpPD8wI6lyKW0>;`$egCH!mr<(w7tDu@_Hx~GOS{LX_|Aly0-R1 z-%ADMvZ{2DApjEueh_9^#Q-n16p7gIK)DrLZ~GOC4D2$BlQ@~0H*OYo+Jv3^f7E*f diff --git a/src/app/api/user/goodreads-shelves/[id]/route.ts b/src/app/api/user/goodreads-shelves/[id]/route.ts index ed072f1..7f5a226 100644 --- a/src/app/api/user/goodreads-shelves/[id]/route.ts +++ b/src/app/api/user/goodreads-shelves/[id]/route.ts @@ -7,9 +7,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { z } from 'zod'; const logger = RMABLogger.create('API.GoodreadsShelves'); +const UpdateGoodreadsSchema = z.object({ + rssUrl: z.string().url('Must be a valid URL'), +}); + /** * DELETE /api/user/goodreads-shelves/[id] * Remove a Goodreads shelf subscription (ownership check) @@ -48,3 +54,57 @@ export async function DELETE( } }); } + +/** + * PATCH /api/user/goodreads-shelves/[id] + * Update a Goodreads shelf subscription + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const shelf = await prisma.goodreadsShelf.findUnique({ where: { id } }); + + if (!shelf) { + return NextResponse.json({ error: 'Shelf not found' }, { status: 404 }); + } + + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { rssUrl } = UpdateGoodreadsSchema.parse(body); + + // Force re-fetch by clearing metadata + const updated = await prisma.goodreadsShelf.update({ + where: { id }, + data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null }, + }); + + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + + return NextResponse.json({ success: true, shelf: updated }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 }); + } + logger.error('Failed to update shelf', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to update shelf' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts index 479b146..b7f94a5 100644 --- a/src/app/api/user/hardcover-shelves/[id]/route.ts +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -7,9 +7,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getEncryptionService } from '@/lib/services/encryption.service'; +import { z } from 'zod'; const logger = RMABLogger.create('API.HardcoverShelves'); +const UpdateHardcoverSchema = z.object({ + listId: z.string().min(1, 'List ID is required').optional(), + apiToken: z.string().optional(), +}); + /** * DELETE /api/user/hardcover-shelves/[id] * Remove a Hardcover shelf subscription (ownership check) @@ -53,3 +61,84 @@ export async function DELETE( } }); } + +/** + * PATCH /api/user/hardcover-shelves/[id] + * Update a Hardcover shelf subscription + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + const shelf = await prisma.hardcoverShelf.findUnique({ where: { id } }); + + if (!shelf) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }); + } + + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await request.json(); + const { listId, apiToken } = UpdateHardcoverSchema.parse(body); + + const updateData: any = {}; + let needsResync = false; + + if (listId && listId !== shelf.listId) { + updateData.listId = listId; + needsResync = true; + } + + if (apiToken && apiToken.trim() !== '') { + const cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ') + ? apiToken.trim().slice(7).trim() + : apiToken.trim(); + const encryptionService = getEncryptionService(); + updateData.apiToken = encryptionService.encrypt(cleanedToken); + needsResync = true; + } + + // If we are forcing a resync due to a change, clear metadata + if (needsResync) { + updateData.lastSyncAt = null; + updateData.bookCount = null; + updateData.coverUrls = null; + } + + const updated = await prisma.hardcoverShelf.update({ + where: { id }, + data: updateData, + }); + + if (needsResync) { + try { + const jobQueue = getJobQueueService(); + await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return NextResponse.json({ success: true, shelf: updated }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 }); + } + logger.error('Failed to update list', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to update list' }, { status: 500 }); + } + }); +} diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index afe7879..b1bfb90 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -14,6 +14,7 @@ import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsM import { usePreferences } from '@/contexts/PreferencesContext'; import { cn } from '@/lib/utils/cn'; import { Modal } from '@/components/ui/Modal'; +import { ManageShelfModal } from '@/components/ui/ManageShelfModal'; import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; function formatRelativeTime(dateStr: string | null): string { @@ -41,6 +42,7 @@ export function ShelvesSection() { const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [showAddShelf, setShowAddShelf] = useState(false); const [selectedAsin, setSelectedAsin] = useState(null); + const [manageShelf, setManageShelf] = useState(null); const handleDelete = async (shelf: GenericShelf) => { try { @@ -128,6 +130,7 @@ export function ShelvesSection() { onDelete={() => handleDelete(shelf)} onConfirmDelete={() => setConfirmDeleteId(shelf.id)} onCancelDelete={() => setConfirmDeleteId(null)} + onManage={() => setManageShelf(shelf)} onBookClick={(asin) => setSelectedAsin(asin)} /> ))} @@ -142,6 +145,12 @@ export function ShelvesSection() { onClose={() => setShowAddShelf(false)} /> + setManageShelf(null)} + shelf={manageShelf} + /> + {selectedAsin && ( void; onConfirmDelete: () => void; onCancelDelete: () => void; + onManage: () => void; onBookClick: (asin: string) => void; } @@ -255,6 +265,7 @@ function ShelfCard({ onDelete, onConfirmDelete, onCancelDelete, + onManage, onBookClick, }: ShelfCardProps) { const displayBooks = shelf.books.slice(0, 6); @@ -267,13 +278,17 @@ function ShelfCard({ const providerIcon = shelf.type === 'goodreads' ? ( - - g - + Goodreads ) : ( - - H - + Hardcover ); return ( @@ -336,25 +351,46 @@ function ShelfCard({ ) : ( - + + + + + + )} diff --git a/src/components/ui/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx index de42246..a69fac4 100644 --- a/src/components/ui/AddShelfModal.tsx +++ b/src/components/ui/AddShelfModal.tsx @@ -161,19 +161,11 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { {provider === 'goodreads' ? ( <>
- - - + Goodreads

@@ -185,19 +177,11 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) { ) : ( <>

- - - + Hardcover

diff --git a/src/components/ui/ManageShelfModal.tsx b/src/components/ui/ManageShelfModal.tsx new file mode 100644 index 0000000..e46fa09 --- /dev/null +++ b/src/components/ui/ManageShelfModal.tsx @@ -0,0 +1,136 @@ +'use client'; + +import React, { useState } from 'react'; +import { Modal } from './Modal'; +import { GenericShelf } from '@/lib/hooks/useShelves'; +import { useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves'; +import { useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; +import { cn } from '@/lib/utils/cn'; + +interface ManageShelfModalProps { + shelf: GenericShelf | null; + isOpen: boolean; + onClose: () => void; +} + +export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) { + const [rssUrl, setRssUrl] = useState(shelf?.type === 'goodreads' ? shelf.sourceId : ''); + const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : ''); + const [apiToken, setApiToken] = useState(''); + + const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads } = useUpdateGoodreadsShelf(); + const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover } = useUpdateHardcoverShelf(); + + // Reset form when shelf changes + React.useEffect(() => { + if (shelf) { + setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : ''); + setListId(shelf.type === 'hardcover' ? shelf.sourceId : ''); + setApiToken(''); + } + }, [shelf]); + + if (!shelf) return null; + + const isUpdating = isUpdatingGoodreads || isUpdatingHardcover; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (shelf.type === 'goodreads') { + if (!rssUrl.trim()) return; + await updateGoodreads(shelf.id, rssUrl.trim()); + } else { + if (!listId.trim()) return; + await updateHardcover(shelf.id, { + listId: listId.trim(), + apiToken: apiToken.trim() || undefined, + }); + } + onClose(); + } catch (err) { + // Error is handled by hook + } + }; + + const isGoodreads = shelf.type === 'goodreads'; + + return ( + +

+
+ {isGoodreads ? ( +
+ + setRssUrl(e.target.value)} + placeholder="https://www.goodreads.com/review/list_rss/..." + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 dark:focus:ring-emerald-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+ ) : ( + <> +
+ + setListId(e.target.value)} + placeholder="e.g., 1234, want-to-read, status-1" + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+
+ + setApiToken(e.target.value)} + placeholder="Paste your Hardcover token here..." + className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors" + disabled={isUpdating} + /> +
+ + )} + +
+ + +
+
+
+ + ); +} diff --git a/src/lib/hooks/useGoodreadsShelves.ts b/src/lib/hooks/useGoodreadsShelves.ts index c803663..d67477b 100644 --- a/src/lib/hooks/useGoodreadsShelves.ts +++ b/src/lib/hooks/useGoodreadsShelves.ts @@ -125,3 +125,53 @@ export function useDeleteGoodreadsShelf() { return { deleteShelf, isLoading, error }; } + +export function useUpdateGoodreadsShelf() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const updateShelf = async (shelfId: string, rssUrl: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth( + `/api/user/goodreads-shelves/${shelfId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rssUrl }), + }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to update shelf'); + } + + // Revalidate shelves list + mutate( + (key) => + typeof key === 'string' && + key.includes('/api/user/goodreads-shelves'), + ); + mutate( + (key) => typeof key === 'string' && key.includes('/api/user/shelves'), + ); + + return data.shelf as GoodreadsShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { updateShelf, isLoading, error }; +} diff --git a/src/lib/hooks/useHardcoverShelves.ts b/src/lib/hooks/useHardcoverShelves.ts index 6dd24a6..f9a4bcc 100644 --- a/src/lib/hooks/useHardcoverShelves.ts +++ b/src/lib/hooks/useHardcoverShelves.ts @@ -133,3 +133,56 @@ export function useDeleteHardcoverShelf() { return { deleteShelf, isLoading, error }; } + +export function useUpdateHardcoverShelf() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const updateShelf = async ( + shelfId: string, + updates: { listId?: string; apiToken?: string }, + ) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth( + `/api/user/hardcover-shelves/${shelfId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to update list'); + } + + // Revalidate shelves list + mutate( + (key) => + typeof key === 'string' && + key.includes('/api/user/hardcover-shelves'), + ); + mutate( + (key) => typeof key === 'string' && key.includes('/api/user/shelves'), + ); + + return data.shelf as HardcoverShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { updateShelf, isLoading, error }; +}