Add a manage shelf modal

This commit is contained in:
Rob Walsh
2026-03-03 13:16:23 -07:00
parent 8f8387abff
commit c57d0c1492
9 changed files with 459 additions and 50 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="w-9 group-hover:rotate-12 transition-all duration-300" fill="none" viewBox="0 0 40 40"><path d="M12.8889 32.5982C12.666 31.7661 13.1598 30.9108 13.9919 30.6879L30.2971 26.3189C31.1292 26.096 31.9845 26.5898 32.2075 27.4219L32.8739 29.9089C33.1711 31.0183 32.5127 32.1587 31.4033 32.456L18.1113 36.0176C15.8924 36.6121 13.6116 35.2953 13.0171 33.0764L12.8889 32.5982Z" fill="#4F46E5"></path><path d="M7.62314 12.946C7.05137 10.8121 8.3177 8.61876 10.4516 8.04699L16.8851 32.0571L13.0214 33.0924L7.62314 12.946Z" fill="#4F46E5"></path><path d="M29.3358 24.432L31.2677 23.9144L32.3584 27.985C32.6443 29.052 32.0111 30.1486 30.9442 30.4345L29.3358 24.432Z" fill="#4338CA"></path><path d="M26.4446 5.91475C26.1474 4.80529 25.007 4.14688 23.8975 4.44416L10.5286 8.02636C9.41911 8.32364 8.7607 9.46403 9.05798 10.5735L14.9532 32.5748L22.6461 30.5135C23.1986 30.3654 23.5265 29.7975 23.3785 29.245C23.2304 28.6925 23.5583 28.1245 24.1108 27.9765L29.7949 26.4535C30.9043 26.1562 31.5628 25.0158 31.2655 23.9063L26.4446 5.91475Z" fill="#6366F1"></path><path d="M21.0947 11.2811C21.145 10.6645 21.9408 10.4512 22.2927 10.9601L22.442 11.1761C22.5512 11.3341 22.724 11.4365 22.9151 11.4565L23.2375 11.4902C23.838 11.553 24.0445 12.3235 23.5558 12.6781L23.2935 12.8685C23.138 12.9813 23.0395 13.1564 23.0239 13.3479L23.0026 13.6096C22.9523 14.2262 22.1564 14.4394 21.8046 13.9306L21.6553 13.7146C21.546 13.5566 21.3732 13.4542 21.1821 13.4342L20.8598 13.4005C20.2592 13.3377 20.0528 12.5672 20.5415 12.2126L20.8038 12.0222C20.9593 11.9094 21.0577 11.7343 21.0734 11.5428L21.0947 11.2811Z" fill="#312E81"></path><path d="M18.3031 16.3181C18.3533 15.7015 19.1492 15.4882 19.501 15.9971L20.5634 17.5337C20.6727 17.6917 20.8455 17.7941 21.0366 17.8141L22.9139 18.0104C23.5144 18.0732 23.7208 18.8436 23.2321 19.1983L21.7045 20.3069C21.549 20.4197 21.4506 20.5949 21.435 20.7863L21.2832 22.6482C21.2329 23.2649 20.4371 23.4781 20.0852 22.9692L19.0228 21.4327C18.9136 21.2747 18.7407 21.1722 18.5497 21.1522L16.6724 20.956C16.0719 20.8932 15.8654 20.1227 16.3541 19.7681L17.8817 18.6594C18.0372 18.5466 18.1357 18.3715 18.1513 18.18L18.3031 16.3181Z" fill="#312E81"></path><path d="M14.9532 32.5748C14.6571 31.4697 15.3129 30.3339 16.4179 30.0378L29.8719 26.4328L30.9441 30.4345L17.4902 34.0395C16.3851 34.3356 15.2493 33.6798 14.9532 32.5748Z" fill="#EEF2FF"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -7,9 +7,15 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; 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 logger = RMABLogger.create('API.GoodreadsShelves');
const UpdateGoodreadsSchema = z.object({
rssUrl: z.string().url('Must be a valid URL'),
});
/** /**
* DELETE /api/user/goodreads-shelves/[id] * DELETE /api/user/goodreads-shelves/[id]
* Remove a Goodreads shelf subscription (ownership check) * 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 });
}
});
}
@@ -7,9 +7,17 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; 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 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] * DELETE /api/user/hardcover-shelves/[id]
* Remove a Hardcover shelf subscription (ownership check) * 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 });
}
});
}
+60 -24
View File
@@ -14,6 +14,7 @@ import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsM
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { ManageShelfModal } from '@/components/ui/ManageShelfModal';
import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves'; import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
function formatRelativeTime(dateStr: string | null): string { function formatRelativeTime(dateStr: string | null): string {
@@ -41,6 +42,7 @@ export function ShelvesSection() {
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [showAddShelf, setShowAddShelf] = useState(false); const [showAddShelf, setShowAddShelf] = useState(false);
const [selectedAsin, setSelectedAsin] = useState<string | null>(null); const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
const [manageShelf, setManageShelf] = useState<GenericShelf | null>(null);
const handleDelete = async (shelf: GenericShelf) => { const handleDelete = async (shelf: GenericShelf) => {
try { try {
@@ -128,6 +130,7 @@ export function ShelvesSection() {
onDelete={() => handleDelete(shelf)} onDelete={() => handleDelete(shelf)}
onConfirmDelete={() => setConfirmDeleteId(shelf.id)} onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
onCancelDelete={() => setConfirmDeleteId(null)} onCancelDelete={() => setConfirmDeleteId(null)}
onManage={() => setManageShelf(shelf)}
onBookClick={(asin) => setSelectedAsin(asin)} onBookClick={(asin) => setSelectedAsin(asin)}
/> />
))} ))}
@@ -142,6 +145,12 @@ export function ShelvesSection() {
onClose={() => setShowAddShelf(false)} onClose={() => setShowAddShelf(false)}
/> />
<ManageShelfModal
isOpen={!!manageShelf}
onClose={() => setManageShelf(null)}
shelf={manageShelf}
/>
{selectedAsin && ( {selectedAsin && (
<AudiobookDetailsModal <AudiobookDetailsModal
asin={selectedAsin} asin={selectedAsin}
@@ -244,6 +253,7 @@ interface ShelfCardProps {
onDelete: () => void; onDelete: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
onCancelDelete: () => void; onCancelDelete: () => void;
onManage: () => void;
onBookClick: (asin: string) => void; onBookClick: (asin: string) => void;
} }
@@ -255,6 +265,7 @@ function ShelfCard({
onDelete, onDelete,
onConfirmDelete, onConfirmDelete,
onCancelDelete, onCancelDelete,
onManage,
onBookClick, onBookClick,
}: ShelfCardProps) { }: ShelfCardProps) {
const displayBooks = shelf.books.slice(0, 6); const displayBooks = shelf.books.slice(0, 6);
@@ -267,13 +278,17 @@ function ShelfCard({
const providerIcon = const providerIcon =
shelf.type === 'goodreads' ? ( shelf.type === 'goodreads' ? (
<span className="text-amber-600 dark:text-amber-400 font-bold ml-2"> <img
g src="/goodreads-icon.png"
</span> alt="Goodreads"
className="w-5 h-5 ml-2 object-contain"
/>
) : ( ) : (
<span className="text-indigo-600 dark:text-indigo-400 font-bold ml-2"> <img
H src="/hardcover.svg"
</span> alt="Hardcover"
className="w-5 h-5 ml-2 object-contain"
/>
); );
return ( return (
@@ -336,25 +351,46 @@ function ShelfCard({
</button> </button>
</div> </div>
) : ( ) : (
<button <div className="flex items-center gap-1">
onClick={onConfirmDelete} <button
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100" onClick={onManage}
title="Remove shelf" className="p-2 text-gray-300 hover:text-blue-500 dark:text-gray-600 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
> title="Manage shelf"
<svg
className="w-[18px] h-[18px]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
> >
<path <svg
strokeLinecap="round" className="w-[18px] h-[18px]"
strokeLinejoin="round" fill="none"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" stroke="currentColor"
/> viewBox="0 0 24 24"
</svg> strokeWidth={1.5}
</button> >
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
</button>
<button
onClick={onConfirmDelete}
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove shelf"
>
<svg
className="w-[18px] h-[18px]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
)} )}
</div> </div>
</div> </div>
+10 -26
View File
@@ -161,19 +161,11 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
{provider === 'goodreads' ? ( {provider === 'goodreads' ? (
<> <>
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0"> <div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
<svg <img
className="w-5 h-5 text-amber-600 dark:text-amber-400" src="/goodreads-icon.png"
fill="none" alt="Goodreads"
stroke="currentColor" className="w-5 h-5 object-contain"
viewBox="0 0 24 24" />
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.5 8.257a4.5 4.5 0 007.244 1.242"
/>
</svg>
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed"> <p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
@@ -185,19 +177,11 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
) : ( ) : (
<> <>
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center ring-1 ring-indigo-200/50 dark:ring-indigo-500/10 flex-shrink-0"> <div className="w-11 h-11 rounded-xl bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center ring-1 ring-indigo-200/50 dark:ring-indigo-500/10 flex-shrink-0">
<svg <img
className="w-5 h-5 text-indigo-600 dark:text-indigo-400" src="/hardcover.svg"
fill="none" alt="Hardcover"
stroke="currentColor" className="w-6 h-6 object-contain"
viewBox="0 0 24 24" />
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed"> <p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
+136
View File
@@ -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 (
<Modal isOpen={isOpen} onClose={onClose} title={`Manage ${shelf.name}`}>
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-5">
{isGoodreads ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Goodreads RSS URL
</label>
<input
type="url"
required
value={rssUrl}
onChange={(e) => 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}
/>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hardcover List ID or Slug
</label>
<input
type="text"
required
value={listId}
onChange={(e) => 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}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New API Token <span className="text-gray-400 dark:text-gray-500 font-normal">(Leave blank to keep current)</span>
</label>
<input
type="password"
value={apiToken}
onChange={(e) => 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}
/>
</div>
</>
)}
<div className="flex gap-3 justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
disabled={isUpdating}
>
Cancel
</button>
<button
type="submit"
disabled={isUpdating}
className={cn(
'px-6 py-2 text-sm font-medium text-white rounded-xl shadow-sm transition-colors',
isGoodreads
? 'bg-amber-600 hover:bg-amber-700'
: 'bg-indigo-600 hover:bg-indigo-700',
isUpdating && 'opacity-50 cursor-not-allowed',
)}
>
{isUpdating ? 'Saving...' : 'Update & Re-sync'}
</button>
</div>
</form>
</div>
</Modal>
);
}
+50
View File
@@ -125,3 +125,53 @@ export function useDeleteGoodreadsShelf() {
return { deleteShelf, isLoading, error }; return { deleteShelf, isLoading, error };
} }
export function useUpdateGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 };
}
+53
View File
@@ -133,3 +133,56 @@ export function useDeleteHardcoverShelf() {
return { deleteShelf, isLoading, error }; return { deleteShelf, isLoading, error };
} }
export function useUpdateHardcoverShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 };
}