mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Merge pull request #126 from brombomb/hardcover-api
Unified Reading Shelves & Hardcover Integration
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ export async function POST(request: NextRequest) {
|
||||
// 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) });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Component: Hardcover Shelf Delete Route
|
||||
* Documentation: documentation/backend/services/hardcover-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';
|
||||
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)
|
||||
*/
|
||||
export async function DELETE(
|
||||
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 });
|
||||
}
|
||||
|
||||
// Ownership check
|
||||
if (shelf.userId !== req.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.hardcoverShelf.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete list', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete list' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Component: Hardcover Shelves API Routes
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fetchHardcoverList } from '@/lib/services/hardcover-sync.service';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.HardcoverShelves');
|
||||
|
||||
const AddShelfSchema = z.object({
|
||||
listId: z.string().min(1, { message: 'List ID is required' }),
|
||||
apiToken: z.string().min(1, { message: 'API Token is required' }),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/hardcover-shelves
|
||||
* List the current user's Hardcover lists 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 shelves = await prisma.hardcoverShelf.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const shelvesWithMeta = shelves.map((shelf) => {
|
||||
let books: {
|
||||
coverUrl: string;
|
||||
asin: string | null;
|
||||
title: string;
|
||||
author: string;
|
||||
}[] = [];
|
||||
if (shelf.coverUrls) {
|
||||
const parsed = JSON.parse(shelf.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<string, unknown>;
|
||||
return {
|
||||
coverUrl: (obj.coverUrl as string) || '',
|
||||
asin: (obj.asin as string) || null,
|
||||
title: (obj.title as string) || '',
|
||||
author: (obj.author as string) || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: shelf.id,
|
||||
name: shelf.name,
|
||||
listId: shelf.listId,
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount ?? null,
|
||||
books,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
|
||||
} catch (error) {
|
||||
logger.error('Failed to list Hardcover lists', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to list Hardcover lists' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/hardcover-shelves
|
||||
* Add a new Hardcover list subscription
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
let { listId, apiToken } = AddShelfSchema.parse(body);
|
||||
|
||||
// Clean up token in case user pasted "Bearer " prefix
|
||||
apiToken = apiToken.trim();
|
||||
if (apiToken.toLowerCase().startsWith('bearer ')) {
|
||||
apiToken = apiToken.slice(7).trim();
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.hardcoverShelf.findUnique({
|
||||
where: { userId_listId: { userId: req.user.id, listId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'DuplicateShelf',
|
||||
message: 'You have already added this list',
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate by fetching the Hardcover GraphQL feed
|
||||
let listName: string;
|
||||
let bookCount: number;
|
||||
let initialBooks: {
|
||||
coverUrl: string;
|
||||
asin: null;
|
||||
title: string;
|
||||
author: string;
|
||||
}[] = [];
|
||||
try {
|
||||
const fetchedData = await fetchHardcoverList(apiToken, listId);
|
||||
listName = fetchedData.listName;
|
||||
bookCount = fetchedData.books.length;
|
||||
initialBooks = fetchedData.books
|
||||
.filter((b) => b.coverUrl)
|
||||
.slice(0, 8)
|
||||
.map((b) => ({
|
||||
coverUrl: b.coverUrl!,
|
||||
asin: null,
|
||||
title: b.title,
|
||||
author: b.author,
|
||||
}));
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'InvalidHardcoverList',
|
||||
message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
const encryptedToken = encryptionService.encrypt(apiToken);
|
||||
|
||||
const shelf = await prisma.hardcoverShelf.create({
|
||||
data: {
|
||||
userId: req.user.id,
|
||||
name: listName,
|
||||
listId,
|
||||
apiToken: encryptedToken,
|
||||
bookCount,
|
||||
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.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
|
||||
logger.info(
|
||||
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
shelf: {
|
||||
id: shelf.id,
|
||||
name: shelf.name,
|
||||
listId: shelf.listId,
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount,
|
||||
books: initialBooks,
|
||||
},
|
||||
bookCount,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to add Hardcover list', {
|
||||
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: 'Failed to add Hardcover list' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11,7 +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 { ShelvesSection } from '@/components/profile/ShelvesSection';
|
||||
|
||||
const statConfig = [
|
||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||
@@ -139,8 +139,8 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Goodreads Shelves */}
|
||||
<GoodreadsShelvesSection />
|
||||
{/* Generic Shelves Section */}
|
||||
<ShelvesSection />
|
||||
|
||||
{/* Active Downloads */}
|
||||
{activeDownloads.length > 0 && (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { VersionBadge } from '@/components/ui/VersionBadge';
|
||||
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
|
||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export function Header() {
|
||||
@@ -21,8 +21,9 @@ export function Header() {
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
const [showBookDate, setShowBookDate] = useState(false);
|
||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||
const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
|
||||
const [showAddShelfModal, setShowAddShelfModal] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } =
|
||||
useSmartDropdownPosition(showUserMenu);
|
||||
|
||||
// Check if user can change password (local users only)
|
||||
const canChangePassword = user?.authProvider === 'local';
|
||||
@@ -44,16 +45,14 @@ export function Header() {
|
||||
|
||||
const response = await fetch('/api/bookdate/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// Show BookDate to any user with verified and enabled configuration
|
||||
setShowBookDate(
|
||||
data.config &&
|
||||
data.config.isVerified &&
|
||||
data.config.isEnabled
|
||||
data.config && data.config.isVerified && data.config.isEnabled,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to check BookDate config:', error);
|
||||
@@ -95,11 +94,11 @@ export function Header() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowAddGoodreadsModal(true);
|
||||
setShowAddShelfModal(true);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Add Goodreads Shelf
|
||||
Add Shelf
|
||||
</button>
|
||||
{canChangePassword && (
|
||||
<button
|
||||
@@ -206,8 +205,18 @@ export function Header() {
|
||||
className="md:hidden p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
@@ -218,12 +227,32 @@ export function Header() {
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{showMobileMenu ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
@@ -327,7 +356,9 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
{/* User menu dropdown (rendered via portal) */}
|
||||
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)}
|
||||
{typeof window !== 'undefined' &&
|
||||
userMenuDropdown &&
|
||||
createPortal(userMenuDropdown, document.body)}
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<ChangePasswordModal
|
||||
@@ -335,10 +366,10 @@ export function Header() {
|
||||
onClose={() => setShowChangePasswordModal(false)}
|
||||
/>
|
||||
|
||||
{/* Add Goodreads Shelf Modal */}
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddGoodreadsModal}
|
||||
onClose={() => setShowAddGoodreadsModal(false)}
|
||||
{/* Add Shelf Modal */}
|
||||
<AddShelfModal
|
||||
isOpen={showAddShelfModal}
|
||||
onClose={() => setShowAddShelfModal(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
|
||||
+189
-57
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Component: Goodreads Shelves Section (Profile Page)
|
||||
* Component: Combined 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 { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
||||
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
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 {
|
||||
if (!dateStr) return 'Never';
|
||||
@@ -26,54 +31,88 @@ function formatRelativeTime(dateStr: string | null): string {
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function GoodreadsShelvesSection() {
|
||||
const { shelves, isLoading } = useGoodreadsShelves();
|
||||
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf();
|
||||
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<string | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (shelfId: string) => {
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [showAddShelf, setShowAddShelf] = useState(false);
|
||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||
const [manageShelf, setManageShelf] = useState<GenericShelf | null>(null);
|
||||
|
||||
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>
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 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">
|
||||
<svg className="w-[18px] h-[18px] text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" 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" />
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-500/10 dark:to-teal-500/10 flex items-center justify-center ring-1 ring-emerald-200/50 dark:ring-emerald-500/10">
|
||||
<svg
|
||||
className="w-[18px] h-[18px] text-emerald-600 dark:text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
|
||||
Goodreads Shelves
|
||||
Shelves
|
||||
</h2>
|
||||
{!isLoading && shelves.length > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected
|
||||
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'}{' '}
|
||||
connected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
{shelves.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAddShelf(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -88,23 +127,30 @@ export function GoodreadsShelvesSection() {
|
||||
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)}
|
||||
onManage={() => setManageShelf(shelf)}
|
||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState onAdd={() => setShowAddModal(true)} />
|
||||
<EmptyState onAdd={() => setShowAddShelf(true)} />
|
||||
)}
|
||||
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
{/* Modals */}
|
||||
<AddShelfModal
|
||||
isOpen={showAddShelf}
|
||||
onClose={() => setShowAddShelf(false)}
|
||||
/>
|
||||
|
||||
<ManageShelfModal
|
||||
isOpen={!!manageShelf}
|
||||
onClose={() => setManageShelf(null)}
|
||||
shelf={manageShelf}
|
||||
/>
|
||||
|
||||
{/* Audiobook Detail Modal (read-only) */}
|
||||
{selectedAsin && (
|
||||
<AudiobookDetailsModal
|
||||
asin={selectedAsin}
|
||||
@@ -122,9 +168,19 @@ export function GoodreadsShelvesSection() {
|
||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700/40 p-10 sm:p-14 text-center">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl 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 mb-5 ring-1 ring-amber-200/50 dark:ring-amber-500/10">
|
||||
<svg className="w-7 h-7 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" 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" />
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-500/10 dark:to-teal-500/10 flex items-center justify-center mb-5 ring-1 ring-emerald-200/50 dark:ring-emerald-500/10">
|
||||
<svg
|
||||
className="w-7 h-7 text-emerald-500 dark:text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
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>
|
||||
|
||||
@@ -132,15 +188,26 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||
Connect your reading list
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
|
||||
Link a Goodreads shelf 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.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Your First Shelf
|
||||
</button>
|
||||
@@ -166,7 +233,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]',
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
|
||||
/>
|
||||
@@ -179,13 +246,14 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
||||
/* ─── Shelf Card ─── */
|
||||
|
||||
interface ShelfCardProps {
|
||||
shelf: GoodreadsShelf;
|
||||
shelf: GenericShelf;
|
||||
squareCovers: boolean;
|
||||
isDeleting: boolean;
|
||||
isConfirmingDelete: boolean;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onManage: () => void;
|
||||
onBookClick: (asin: string) => void;
|
||||
}
|
||||
|
||||
@@ -197,20 +265,44 @@ function ShelfCard({
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onManage,
|
||||
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 remainingCount = Math.max(
|
||||
0,
|
||||
(shelf.bookCount || 0) - displayBooks.length,
|
||||
);
|
||||
const isSyncing = !shelf.lastSyncAt;
|
||||
|
||||
const providerIcon =
|
||||
shelf.type === 'goodreads' ? (
|
||||
<img
|
||||
src="/goodreads-icon.png"
|
||||
alt="Goodreads"
|
||||
className="w-5 h-5 ml-2 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/hardcover-icon.svg"
|
||||
alt="Hardcover"
|
||||
className="w-5 h-5 ml-2 object-contain"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
|
||||
{/* Top: Shelf info + actions */}
|
||||
<div className={cn('flex items-start justify-between', (hasCovers || isSyncing) && 'mb-5')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start justify-between',
|
||||
(hasCovers || isSyncing) && 'mb-5',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
|
||||
{shelf.name}
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
|
||||
{shelf.name} {providerIcon}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{shelf.bookCount != null && (
|
||||
@@ -259,22 +351,58 @@ function ShelfCard({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<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 className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onManage}
|
||||
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
|
||||
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>
|
||||
|
||||
{/* Bottom: Stacked book covers */}
|
||||
{hasCovers ? (
|
||||
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
|
||||
<CoverStack
|
||||
books={displayBooks}
|
||||
remainingCount={remainingCount}
|
||||
squareCovers={squareCovers}
|
||||
onBookClick={onBookClick}
|
||||
/>
|
||||
) : isSyncing ? (
|
||||
<div className="flex items-end">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
@@ -282,7 +410,7 @@ function ShelfCard({
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
|
||||
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]',
|
||||
)}
|
||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
|
||||
/>
|
||||
@@ -322,7 +450,7 @@ function CoverStack({
|
||||
'transition-all duration-300 ease-out',
|
||||
hoveredIndex === i && 'scale-[1.18] shadow-xl',
|
||||
coverSize,
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default'
|
||||
book.asin ? 'cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
style={{
|
||||
marginLeft: i > 0 ? '-16px' : 0,
|
||||
@@ -331,7 +459,11 @@ function CoverStack({
|
||||
onMouseEnter={() => setHoveredIndex(i)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
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
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={book.coverUrl}
|
||||
@@ -346,7 +478,7 @@ function CoverStack({
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
|
||||
coverSize
|
||||
coverSize,
|
||||
)}
|
||||
style={{ marginLeft: '-16px', zIndex: 0 }}
|
||||
>
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Component: Add Goodreads Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
|
||||
interface AddGoodreadsShelfModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||
|
||||
export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) {
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const { addShelf, isLoading, error } = useAddGoodreadsShelf();
|
||||
|
||||
const validateUrl = (url: string): boolean => {
|
||||
if (!url.trim()) {
|
||||
setValidationError('RSS URL is required');
|
||||
return false;
|
||||
}
|
||||
if (!GOODREADS_RSS_PATTERN.test(url)) {
|
||||
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
|
||||
return false;
|
||||
}
|
||||
setValidationError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateUrl(rssUrl)) return;
|
||||
|
||||
try {
|
||||
const shelf = await addShelf(rssUrl);
|
||||
setSuccess(true);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRssUrl('');
|
||||
setValidationError('');
|
||||
setSuccess(false);
|
||||
setSuccessMessage('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Goodreads Shelf" size="sm">
|
||||
<div className="space-y-5">
|
||||
{/* Visual header */}
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
||||
<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 className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" 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 className="min-w-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success alert */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<Input
|
||||
type="url"
|
||||
label="Goodreads RSS URL"
|
||||
value={rssUrl}
|
||||
onChange={(e) => {
|
||||
setRssUrl(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
error={validationError}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
||||
Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isLoading}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Add Shelf
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Component: Add Shelf Modal
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
|
||||
interface AddShelfModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||
|
||||
export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
||||
const [provider, setProvider] = useState<'goodreads' | 'hardcover'>(
|
||||
'goodreads',
|
||||
);
|
||||
|
||||
// Goodreads State
|
||||
const [rssUrl, setRssUrl] = useState('');
|
||||
|
||||
// Hardcover State
|
||||
const [apiToken, setApiToken] = useState('');
|
||||
const [listType, setListType] = useState<'status' | 'custom'>('status');
|
||||
const [statusId, setStatusId] = useState('1'); // 1 = Want to Read
|
||||
const [customListId, setCustomListId] = useState('');
|
||||
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
const {
|
||||
addShelf: addGoodreads,
|
||||
isLoading: isGoodreadsLoading,
|
||||
error: goodreadsError,
|
||||
} = useAddGoodreadsShelf();
|
||||
const {
|
||||
addShelf: addHardcover,
|
||||
isLoading: isHardcoverLoading,
|
||||
error: hardcoverError,
|
||||
} = useAddHardcoverShelf();
|
||||
|
||||
const isLoading = isGoodreadsLoading || isHardcoverLoading;
|
||||
const currentError =
|
||||
provider === 'goodreads' ? goodreadsError : hardcoverError;
|
||||
|
||||
const validateInput = (): boolean => {
|
||||
if (provider === 'goodreads') {
|
||||
if (!rssUrl.trim()) {
|
||||
setValidationError('RSS URL is required');
|
||||
return false;
|
||||
}
|
||||
if (!GOODREADS_RSS_PATTERN.test(rssUrl)) {
|
||||
setValidationError(
|
||||
'Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!apiToken.trim()) {
|
||||
setValidationError('Hardcover API Token is required');
|
||||
return false;
|
||||
}
|
||||
if (listType === 'custom' && !customListId.trim()) {
|
||||
setValidationError('Hardcover List URL or Slug is required');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setValidationError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validateInput()) return;
|
||||
|
||||
try {
|
||||
if (provider === 'goodreads') {
|
||||
const shelf = await addGoodreads(rssUrl);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
} else {
|
||||
const finalId =
|
||||
listType === 'status' ? `status-${statusId}` : customListId.trim();
|
||||
let cleanedToken = apiToken.trim();
|
||||
if (cleanedToken.toLowerCase().startsWith('bearer ')) {
|
||||
cleanedToken = cleanedToken.slice(7).trim();
|
||||
}
|
||||
const shelf = await addHardcover(cleanedToken, finalId);
|
||||
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
|
||||
setApiToken('');
|
||||
setCustomListId('');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Error is handled by the hooks
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setRssUrl('');
|
||||
setApiToken('');
|
||||
setCustomListId('');
|
||||
setValidationError('');
|
||||
setSuccess(false);
|
||||
setSuccessMessage('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Shelf" size="sm">
|
||||
<div className="space-y-5">
|
||||
{/* Provider Selection Tabs */}
|
||||
<div className="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${
|
||||
provider === 'goodreads'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setProvider('goodreads');
|
||||
setValidationError('');
|
||||
}}
|
||||
>
|
||||
Goodreads
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-all ${
|
||||
provider === 'hardcover'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setProvider('hardcover');
|
||||
setValidationError('');
|
||||
}}
|
||||
>
|
||||
Hardcover
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Visual header */}
|
||||
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
||||
{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">
|
||||
<img
|
||||
src="/goodreads-icon.png"
|
||||
alt="Goodreads"
|
||||
className="w-5 h-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Paste your Goodreads shelf RSS URL. Books will be
|
||||
automatically requested.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<img
|
||||
src="/hardcover-icon.svg"
|
||||
alt="Hardcover"
|
||||
className="w-6 h-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
Provide your Hardcover API token and select the list you want
|
||||
to sync.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success alert */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-emerald-600 dark:text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{successMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error alert */}
|
||||
{currentError && (
|
||||
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{currentError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{provider === 'goodreads' ? (
|
||||
<div>
|
||||
<Input
|
||||
type="url"
|
||||
label="Goodreads RSS URL"
|
||||
value={rssUrl}
|
||||
onChange={(e) => {
|
||||
setRssUrl(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||
error={validationError}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
||||
Find it on Goodreads: My Books → select a shelf → RSS
|
||||
link at the bottom of the page.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
label="API Token"
|
||||
value={apiToken}
|
||||
onChange={(e) => {
|
||||
setApiToken(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder="eyJhb..."
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
List to Sync
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
className="form-radio text-indigo-600"
|
||||
checked={listType === 'status'}
|
||||
onChange={() => setListType('status')}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
My Status
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
className="form-radio text-indigo-600"
|
||||
checked={listType === 'custom'}
|
||||
onChange={() => setListType('custom')}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Custom List
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listType === 'status' ? (
|
||||
<div>
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
value={statusId}
|
||||
onChange={(e) => setStatusId(e.target.value)}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
<option value="1">Want to Read</option>
|
||||
<option value="2">Currently Reading</option>
|
||||
<option value="3">Read</option>
|
||||
<option value="4">Did Not Finish</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
label="List URL or Slug"
|
||||
value={customListId}
|
||||
onChange={(e) => {
|
||||
setCustomListId(e.target.value);
|
||||
if (validationError) setValidationError('');
|
||||
}}
|
||||
placeholder="https://hardcover.app/@username/lists/..."
|
||||
error={validationError}
|
||||
disabled={isLoading || success}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isLoading}
|
||||
disabled={isLoading || success}
|
||||
>
|
||||
Add Shelf
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -25,6 +26,12 @@ export function Modal({
|
||||
size = 'md',
|
||||
showCloseButton = true,
|
||||
}: ModalProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Use ref to avoid re-running effect when onClose changes
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
@@ -53,7 +60,7 @@ export function Modal({
|
||||
};
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
if (!isOpen || !mounted) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
@@ -63,8 +70,8 @@ export function Modal({
|
||||
full: 'max-w-[95vw]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
const content = (
|
||||
<div className="fixed inset-0 z-[100] overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
@@ -77,7 +84,7 @@ export function Modal({
|
||||
className={cn(
|
||||
'relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl',
|
||||
'transform transition-all',
|
||||
sizeClasses[size]
|
||||
sizeClasses[size],
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -116,4 +123,6 @@ export function Modal({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
@@ -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<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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Component: Hardcover Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
export interface ShelfBook {
|
||||
coverUrl: string;
|
||||
asin: string | null;
|
||||
title: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface HardcoverShelf {
|
||||
id: string;
|
||||
name: string;
|
||||
listId: string;
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useHardcoverShelves() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/hardcover-shelves' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as HardcoverShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddHardcoverShelf() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const addShelf = async (apiToken: string, listId: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/hardcover-shelves', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiToken, listId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to add list');
|
||||
}
|
||||
|
||||
// Revalidate shelves list
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === 'string' &&
|
||||
key.includes('/api/user/hardcover-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 { addShelf, isLoading, error };
|
||||
}
|
||||
|
||||
export function useDeleteHardcoverShelf() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const deleteShelf = async (shelfId: string) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(
|
||||
`/api/user/hardcover-shelves/${shelfId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to remove list');
|
||||
}
|
||||
|
||||
// Revalidate shelves list
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === 'string' &&
|
||||
key.includes('/api/user/hardcover-shelves'),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Component: Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ShelfBook } from './useGoodreadsShelves';
|
||||
|
||||
export interface GenericShelf {
|
||||
id: string;
|
||||
type: 'goodreads' | 'hardcover';
|
||||
name: string;
|
||||
sourceId: string; // Either rssUrl or listId
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
|
||||
|
||||
export function useShelves() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const endpoint = accessToken ? '/api/user/shelves' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
});
|
||||
|
||||
return {
|
||||
shelves: (data?.shelves || []) as GenericShelf[],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Component: Sync Goodreads Shelves Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Dedicated processor for syncing Goodreads shelf RSS feeds.
|
||||
* Resolves books to Audible ASINs and creates requests.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SyncGoodreadsShelvesPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process this specific shelf (used for immediate sync on add) */
|
||||
shelfId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise<any> {
|
||||
const { jobId, shelfId, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves');
|
||||
|
||||
logger.info(shelfId
|
||||
? `Starting immediate Goodreads sync for shelf ${shelfId}...`
|
||||
: 'Starting scheduled Goodreads shelves sync...'
|
||||
);
|
||||
|
||||
const { processGoodreadsShelves } = await import('../services/goodreads-sync.service');
|
||||
const stats = await processGoodreadsShelves(logger, {
|
||||
shelfId,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
logger.info('Goodreads sync complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Component: Sync Shelves Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Dedicated processor for syncing all reading shelves (Goodreads, Hardcover).
|
||||
* Resolves books to Audible ASINs and creates requests.
|
||||
*/
|
||||
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SyncShelvesPayload {
|
||||
jobId?: string;
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process this specific shelf (used for immediate sync on add) */
|
||||
shelfId?: string;
|
||||
/** The type of shelf, if shelfId is specified */
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export async function processSyncShelves(
|
||||
payload: SyncShelvesPayload,
|
||||
): Promise<any> {
|
||||
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
|
||||
|
||||
const stats = {
|
||||
shelvesProcessed: 0,
|
||||
booksFound: 0,
|
||||
lookupsPerformed: 0,
|
||||
requestsCreated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
logger.info(
|
||||
shelfId
|
||||
? `Starting immediate ${shelfType} sync for list ${shelfId}...`
|
||||
: 'Starting scheduled shelves sync...',
|
||||
);
|
||||
|
||||
const shouldSyncGoodreads = !shelfType || shelfType === 'goodreads';
|
||||
const shouldSyncHardcover = !shelfType || shelfType === 'hardcover';
|
||||
|
||||
if (shouldSyncGoodreads) {
|
||||
try {
|
||||
const { processGoodreadsShelves } =
|
||||
await import('../services/goodreads-sync.service');
|
||||
const grStats = await processGoodreadsShelves(logger, {
|
||||
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
stats.shelvesProcessed += grStats.shelvesProcessed;
|
||||
stats.booksFound += grStats.booksFound;
|
||||
stats.lookupsPerformed += grStats.lookupsPerformed;
|
||||
stats.requestsCreated += grStats.requestsCreated;
|
||||
stats.errors += grStats.errors;
|
||||
} catch (error) {
|
||||
logger.error('Goodreads sync failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSyncHardcover) {
|
||||
try {
|
||||
const { processHardcoverShelves } =
|
||||
await import('../services/hardcover-sync.service');
|
||||
const hcStats = await processHardcoverShelves(logger, {
|
||||
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
stats.shelvesProcessed += hcStats.shelvesProcessed;
|
||||
stats.booksFound += hcStats.booksFound;
|
||||
stats.lookupsPerformed += hcStats.lookupsPerformed;
|
||||
stats.requestsCreated += hcStats.requestsCreated;
|
||||
stats.errors += hcStats.errors;
|
||||
} catch (error) {
|
||||
logger.error('Hardcover sync failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
stats.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Shelves sync complete', { stats });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: shelfId ? `${shelfType} list synced` : 'Reading shelves synced',
|
||||
...stats,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Component: Hardcover Shelf Sync Service
|
||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||
*
|
||||
* Fetches Hardcover books using their GraphQL API, resolves books to Audible ASINs,
|
||||
* and creates requests via the shared request-creator service.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('HardcoverSync');
|
||||
|
||||
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
||||
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
||||
|
||||
/** Days before retrying a noMatch book */
|
||||
const NO_MATCH_RETRY_DAYS = 7;
|
||||
|
||||
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
|
||||
|
||||
interface HardcoverApiBook {
|
||||
bookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Hardcover List using their GraphQL API.
|
||||
* This handles both 'status_id' user_books or 'list_id' list_books queries.
|
||||
* For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id.
|
||||
*/
|
||||
export async function fetchHardcoverList(
|
||||
apiToken: string,
|
||||
listIdStr: string,
|
||||
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
|
||||
// Check if it's a status list
|
||||
const isStatus = listIdStr.startsWith('status-');
|
||||
|
||||
if (isStatus) {
|
||||
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
||||
const query = `
|
||||
query GetStatusBooks($statusId: Int!) {
|
||||
me {
|
||||
user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) {
|
||||
book {
|
||||
id
|
||||
title
|
||||
contributions {
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
cached_image
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await axios.post(
|
||||
HARDCOVER_API_URL,
|
||||
{ query, variables: { statusId } },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data?.errors) {
|
||||
throw new Error(
|
||||
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const userBooks = response.data?.data?.me?.[0]?.user_books || [];
|
||||
let listName = 'Hardcover Status List';
|
||||
|
||||
// Map status numbers to names
|
||||
const statusNames: Record<number, string> = {
|
||||
1: 'Want to Read',
|
||||
2: 'Currently Reading',
|
||||
3: 'Read',
|
||||
4: 'Did Not Finish',
|
||||
};
|
||||
listName = statusNames[statusId] || `Status ${statusId}`;
|
||||
|
||||
const books: HardcoverApiBook[] = [];
|
||||
for (const item of userBooks) {
|
||||
const book = item.book;
|
||||
if (!book || !book.id) continue;
|
||||
|
||||
const authorName =
|
||||
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
||||
const coverUrl = book.cached_image || book.image?.url || undefined;
|
||||
|
||||
books.push({
|
||||
bookId: book.id.toString(),
|
||||
title: book.title || 'Unknown Title',
|
||||
author: authorName,
|
||||
coverUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return { listName, books };
|
||||
} else {
|
||||
// Original list_books logic
|
||||
let isUuid = false;
|
||||
let isIntId = false;
|
||||
let extractedSlug = listIdStr;
|
||||
|
||||
if (
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
listIdStr,
|
||||
)
|
||||
) {
|
||||
isUuid = true;
|
||||
} else if (/^\d+$/.test(listIdStr)) {
|
||||
isIntId = true;
|
||||
} else {
|
||||
try {
|
||||
if (listIdStr.includes('hardcover.app')) {
|
||||
const url = new URL(
|
||||
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
|
||||
);
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
extractedSlug = parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// use extractedSlug as-is
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
query GetListBooks($listId: Int!) {
|
||||
list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) {
|
||||
list { name }
|
||||
book {
|
||||
id title cached_image image { url }
|
||||
contributions { author { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const queryUuid = `
|
||||
query GetListBooksUuid($listId: uuid!) {
|
||||
list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) {
|
||||
list { name }
|
||||
book {
|
||||
id title cached_image image { url }
|
||||
contributions { author { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const querySlug = `
|
||||
query GetListBooksBySlug($slug: String!) {
|
||||
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
||||
name
|
||||
list_books(limit: 100, order_by: {id: desc}) {
|
||||
book {
|
||||
id title cached_image image { url }
|
||||
contributions { author { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const isSlug = !isUuid && !isIntId;
|
||||
const activeQuery = isSlug ? querySlug : isUuid ? queryUuid : query;
|
||||
const variables = isSlug
|
||||
? { slug: extractedSlug }
|
||||
: { listId: isUuid ? listIdStr : parseInt(listIdStr, 10) };
|
||||
|
||||
const response = await axios.post(
|
||||
HARDCOVER_API_URL,
|
||||
{
|
||||
query: activeQuery,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data?.errors) {
|
||||
throw new Error(
|
||||
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
let listName = 'Hardcover List';
|
||||
let listBooks: any[] = [];
|
||||
|
||||
if (isSlug) {
|
||||
const listsData = response.data?.data?.lists || [];
|
||||
if (listsData.length === 0) {
|
||||
throw new Error(`Could not find a list with slug "${extractedSlug}"`);
|
||||
}
|
||||
listName = listsData[0].name || listName;
|
||||
listBooks = listsData[0].list_books || [];
|
||||
} else {
|
||||
listBooks = response.data?.data?.list_books || [];
|
||||
if (listBooks.length > 0 && listBooks[0].list?.name) {
|
||||
listName = listBooks[0].list.name;
|
||||
}
|
||||
}
|
||||
|
||||
const books: HardcoverApiBook[] = [];
|
||||
for (const item of listBooks) {
|
||||
const book = item.book;
|
||||
if (!book || !book.id) continue;
|
||||
|
||||
const authorName =
|
||||
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
||||
const coverUrl = book.cached_image || book.image?.url || undefined;
|
||||
|
||||
books.push({
|
||||
bookId: book.id.toString(),
|
||||
title: book.title || 'Unknown Title',
|
||||
author: authorName,
|
||||
coverUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return { listName, books };
|
||||
}
|
||||
}
|
||||
|
||||
export interface HardcoverSyncStats {
|
||||
shelvesProcessed: number;
|
||||
booksFound: number;
|
||||
lookupsPerformed: number;
|
||||
requestsCreated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface HardcoverSyncOptions {
|
||||
shelfId?: string;
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export async function processHardcoverShelves(
|
||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||
options: HardcoverSyncOptions = {},
|
||||
): Promise<HardcoverSyncStats> {
|
||||
const log = jobLogger || logger;
|
||||
const stats: HardcoverSyncStats = {
|
||||
shelvesProcessed: 0,
|
||||
booksFound: 0,
|
||||
lookupsPerformed: 0,
|
||||
requestsCreated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
const maxLookups =
|
||||
options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
||||
|
||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
||||
const shelves = await prisma.hardcoverShelf.findMany({
|
||||
where: whereClause,
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
if (shelves.length === 0) {
|
||||
log.info(
|
||||
options.shelfId
|
||||
? 'Hardcover list not found'
|
||||
: 'No Hardcover lists configured, skipping',
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Processing ${shelves.length} Hardcover list(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/list)` : ' (unlimited lookups)'}`,
|
||||
);
|
||||
|
||||
for (const shelf of shelves) {
|
||||
try {
|
||||
await processShelf(shelf, stats, log, maxLookups);
|
||||
stats.shelvesProcessed++;
|
||||
} catch (error) {
|
||||
stats.errors++;
|
||||
log.error(
|
||||
`Failed to process list "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Hardcover sync complete: ${stats.shelvesProcessed} lists, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`,
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
async function processShelf(
|
||||
shelf: {
|
||||
id: string;
|
||||
listId: string;
|
||||
apiToken: string;
|
||||
name: string;
|
||||
user: { id: string; plexUsername: string };
|
||||
},
|
||||
stats: HardcoverSyncStats,
|
||||
log:
|
||||
| ReturnType<typeof RMABLogger.forJob>
|
||||
| ReturnType<typeof RMABLogger.create>,
|
||||
maxLookups: number,
|
||||
) {
|
||||
log.info(
|
||||
`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`,
|
||||
);
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
let decryptedToken = shelf.apiToken;
|
||||
try {
|
||||
// Check if the token is encrypted (our new storage method format)
|
||||
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
|
||||
decryptedToken = encryptionService.decrypt(shelf.apiToken);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
`Failed to decrypt API token for user ${shelf.user.plexUsername}`,
|
||||
);
|
||||
}
|
||||
|
||||
let fetchedData: { listName: string; books: HardcoverApiBook[] };
|
||||
try {
|
||||
fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const books = fetchedData.books;
|
||||
stats.booksFound += books.length;
|
||||
log.info(
|
||||
`Found ${books.length} books in list "${shelf.name}" (Hardcover API)`,
|
||||
);
|
||||
|
||||
let lookupsThisCycle = 0;
|
||||
const unlimitedLookups = maxLookups === 0;
|
||||
|
||||
for (const book of books) {
|
||||
let mapping = await prisma.hardcoverBookMapping.findUnique({
|
||||
where: { hardcoverBookId: book.bookId },
|
||||
});
|
||||
|
||||
if (!mapping) {
|
||||
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue;
|
||||
|
||||
mapping = await performAudibleLookup(book, log);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
if (!mapping?.audibleAsin) continue;
|
||||
}
|
||||
|
||||
if (mapping.noMatch) {
|
||||
if (mapping.lastSearchAt) {
|
||||
const daysSinceSearch =
|
||||
(Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (
|
||||
daysSinceSearch >= NO_MATCH_RETRY_DAYS &&
|
||||
(unlimitedLookups || lookupsThisCycle < maxLookups)
|
||||
) {
|
||||
log.info(
|
||||
`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`,
|
||||
);
|
||||
mapping = await performAudibleLookup(book, log, mapping.id);
|
||||
lookupsThisCycle++;
|
||||
stats.lookupsPerformed++;
|
||||
|
||||
if (!mapping?.audibleAsin) continue;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.audibleAsin) {
|
||||
try {
|
||||
const result = await createRequestForUser(shelf.user.id, {
|
||||
asin: mapping.audibleAsin,
|
||||
title: mapping.title,
|
||||
author: mapping.author,
|
||||
coverArtUrl: mapping.coverUrl || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
stats.requestsCreated++;
|
||||
log.info(
|
||||
`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect enriched book data for display
|
||||
const bookIds = books.map((b) => b.bookId);
|
||||
const mappings =
|
||||
bookIds.length > 0
|
||||
? await prisma.hardcoverBookMapping.findMany({
|
||||
where: { hardcoverBookId: { in: bookIds } },
|
||||
select: {
|
||||
hardcoverBookId: true,
|
||||
audibleAsin: true,
|
||||
title: true,
|
||||
author: true,
|
||||
coverUrl: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const mappingsByBookId = new Map(mappings.map((m) => [m.hardcoverBookId, m]));
|
||||
|
||||
const matchedAsins = mappings
|
||||
.map((m) => m.audibleAsin)
|
||||
.filter((asin): asin is string => !!asin);
|
||||
const cachedCovers =
|
||||
matchedAsins.length > 0
|
||||
? await prisma.audibleCache.findMany({
|
||||
where: { asin: { in: matchedAsins } },
|
||||
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
|
||||
})
|
||||
: [];
|
||||
const coverByAsin = new Map(
|
||||
cachedCovers
|
||||
.filter((c) => c.cachedCoverPath || c.coverArtUrl)
|
||||
.map((c) => {
|
||||
let coverUrl = c.coverArtUrl || '';
|
||||
if (c.cachedCoverPath) {
|
||||
const filename = c.cachedCoverPath.split('/').pop();
|
||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
||||
}
|
||||
return [c.asin, coverUrl] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
const bookData = books
|
||||
.map((b) => {
|
||||
const mapping = mappingsByBookId.get(b.bookId);
|
||||
const coverUrl =
|
||||
coverByAsin.get(mapping?.audibleAsin || '') ||
|
||||
mapping?.coverUrl ||
|
||||
b.coverUrl;
|
||||
if (!coverUrl) return null;
|
||||
return {
|
||||
coverUrl,
|
||||
asin: mapping?.audibleAsin || null,
|
||||
title: mapping?.title || b.title,
|
||||
author: mapping?.author || b.author,
|
||||
};
|
||||
})
|
||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
||||
.slice(0, 8);
|
||||
|
||||
const finalListName =
|
||||
fetchedData.listName !== 'Hardcover List'
|
||||
? fetchedData.listName
|
||||
: shelf.name;
|
||||
|
||||
await prisma.hardcoverShelf.update({
|
||||
where: { id: shelf.id },
|
||||
data: {
|
||||
name: finalListName,
|
||||
lastSyncAt: new Date(),
|
||||
bookCount: books.length,
|
||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function performAudibleLookup(
|
||||
book: HardcoverApiBook,
|
||||
log:
|
||||
| ReturnType<typeof RMABLogger.forJob>
|
||||
| ReturnType<typeof RMABLogger.create>,
|
||||
existingMappingId?: string,
|
||||
): Promise<any> {
|
||||
const audibleService = getAudibleService();
|
||||
|
||||
try {
|
||||
const fullQuery = `${book.title} ${book.author}`;
|
||||
log.info(`Searching Audible for: "${fullQuery}"`);
|
||||
|
||||
let searchResult = await audibleService.search(fullQuery);
|
||||
let firstResult = searchResult.results[0];
|
||||
|
||||
if (!firstResult?.asin) {
|
||||
const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim();
|
||||
if (cleanTitle !== book.title) {
|
||||
const cleanQuery = `${cleanTitle} ${book.author}`;
|
||||
log.info(
|
||||
`No results with full title, retrying without series info: "${cleanQuery}"`,
|
||||
);
|
||||
searchResult = await audibleService.search(cleanQuery);
|
||||
firstResult = searchResult.results[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (firstResult?.asin) {
|
||||
log.info(
|
||||
`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`,
|
||||
);
|
||||
|
||||
const data = {
|
||||
title: firstResult.title,
|
||||
author: firstResult.author,
|
||||
audibleAsin: firstResult.asin,
|
||||
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
|
||||
noMatch: false,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.hardcoverBookMapping.update({
|
||||
where: { id: existingMappingId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
return prisma.hardcoverBookMapping.create({
|
||||
data: { hardcoverBookId: book.bookId, ...data },
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`No Audible match for "${book.title}" by ${book.author}`);
|
||||
|
||||
const noMatchData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
audibleAsin: null,
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.hardcoverBookMapping.update({
|
||||
where: { id: existingMappingId },
|
||||
data: noMatchData,
|
||||
});
|
||||
}
|
||||
return prisma.hardcoverBookMapping.create({
|
||||
data: { hardcoverBookId: book.bookId, ...noMatchData },
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
|
||||
const errorData = {
|
||||
title: book.title,
|
||||
author: book.author,
|
||||
coverUrl: book.coverUrl || null,
|
||||
noMatch: true,
|
||||
lastSearchAt: new Date(),
|
||||
};
|
||||
|
||||
if (existingMappingId) {
|
||||
return prisma.hardcoverBookMapping.update({
|
||||
where: { id: existingMappingId },
|
||||
data: errorData,
|
||||
});
|
||||
}
|
||||
return prisma.hardcoverBookMapping.create({
|
||||
data: { hardcoverBookId: book.bookId, ...errorData },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export type JobType =
|
||||
| 'retry_failed_imports'
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds'
|
||||
| 'sync_goodreads_shelves'
|
||||
| 'sync_reading_shelves'
|
||||
| 'send_notification'
|
||||
// Ebook-specific job types
|
||||
| 'search_ebook'
|
||||
@@ -107,9 +107,10 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
|
||||
export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
||||
export interface SyncShelvesPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
shelfId?: string;
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
@@ -378,10 +379,10 @@ export class JobQueueService {
|
||||
return await processCleanupSeededTorrents(payloadWithJobId);
|
||||
});
|
||||
|
||||
this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
|
||||
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves');
|
||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
||||
this.queue.process('sync_reading_shelves', 1, async (job: BullJob<SyncShelvesPayload>) => {
|
||||
const { processSyncShelves } = await import('../processors/sync-shelves.processor');
|
||||
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_reading_shelves');
|
||||
return await processSyncShelves(payloadWithJobId);
|
||||
});
|
||||
|
||||
// Send notification processor
|
||||
@@ -750,16 +751,17 @@ export class JobQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sync Goodreads shelves job
|
||||
* Add sync reading shelves job
|
||||
*/
|
||||
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> {
|
||||
async addSyncShelvesJob(scheduledJobId?: string, shelfId?: string, shelfType?: 'goodreads' | 'hardcover', maxLookupsPerShelf?: number): Promise<string> {
|
||||
return await this.addJob(
|
||||
'sync_goodreads_shelves',
|
||||
'sync_reading_shelves',
|
||||
{
|
||||
scheduledJobId,
|
||||
shelfId,
|
||||
shelfType,
|
||||
maxLookupsPerShelf,
|
||||
} as SyncGoodreadsShelvesPayload,
|
||||
} as SyncShelvesPayload,
|
||||
{
|
||||
priority: 7,
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Scheduler');
|
||||
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves';
|
||||
|
||||
export interface ScheduledJob {
|
||||
id: string;
|
||||
@@ -59,6 +59,9 @@ export class SchedulerService {
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up deprecated scheduled jobs
|
||||
await this.cleanupDeprecatedJobs();
|
||||
|
||||
// Create default jobs if they don't exist
|
||||
await this.ensureDefaultJobs();
|
||||
|
||||
@@ -127,8 +130,8 @@ export class SchedulerService {
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
name: 'Sync Goodreads Shelves',
|
||||
type: 'sync_goodreads_shelves' as ScheduledJobType,
|
||||
name: 'Sync Reading Shelves',
|
||||
type: 'sync_reading_shelves' as ScheduledJobType,
|
||||
schedule: '0 */6 * * *', // Every 6 hours
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
@@ -167,6 +170,31 @@ export class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any old jobs that are no longer supported
|
||||
*/
|
||||
private async cleanupDeprecatedJobs(): Promise<void> {
|
||||
try {
|
||||
const deprecatedTypes = ['sync_goodreads_shelves'];
|
||||
|
||||
const obsoleteJobs = await prisma.scheduledJob.findMany({
|
||||
where: { type: { in: deprecatedTypes } },
|
||||
});
|
||||
|
||||
for (const job of obsoleteJobs) {
|
||||
if (job.enabled) {
|
||||
await this.unscheduleJob(job);
|
||||
}
|
||||
await prisma.scheduledJob.delete({ where: { id: job.id } });
|
||||
logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup deprecated scheduled jobs', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule all enabled jobs
|
||||
*/
|
||||
@@ -350,8 +378,8 @@ export class SchedulerService {
|
||||
case 'monitor_rss_feeds':
|
||||
bullJobId = await this.triggerMonitorRssFeeds(job);
|
||||
break;
|
||||
case 'sync_goodreads_shelves':
|
||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
||||
case 'sync_reading_shelves':
|
||||
bullJobId = await this.triggerSyncShelves(job);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${job.type}`);
|
||||
@@ -622,10 +650,10 @@ export class SchedulerService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Goodreads shelves sync
|
||||
* Trigger Reading shelves sync
|
||||
*/
|
||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
||||
private async triggerSyncShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncShelvesJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user