mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Refactor shelves UI and jobs
This commit is contained in:
@@ -16,10 +16,13 @@ const logger = RMABLogger.create('API.GoodreadsShelves');
|
|||||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||||
|
|
||||||
const AddShelfSchema = z.object({
|
const AddShelfSchema = z.object({
|
||||||
rssUrl: z.string().url().refine(
|
rssUrl: z
|
||||||
(url) => GOODREADS_RSS_PATTERN.test(url),
|
.string()
|
||||||
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
.url()
|
||||||
),
|
.refine((url) => GOODREADS_RSS_PATTERN.test(url), {
|
||||||
|
message:
|
||||||
|
'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)',
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +43,12 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const shelvesWithMeta = shelves.map((shelf) => {
|
const shelvesWithMeta = shelves.map((shelf) => {
|
||||||
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
|
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
|
||||||
let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
|
let books: {
|
||||||
|
coverUrl: string;
|
||||||
|
asin: string | null;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
}[] = [];
|
||||||
if (shelf.coverUrls) {
|
if (shelf.coverUrls) {
|
||||||
const parsed = JSON.parse(shelf.coverUrls);
|
const parsed = JSON.parse(shelf.coverUrls);
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
@@ -72,8 +80,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
|
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to list shelves', {
|
||||||
return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 });
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to list shelves' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,30 +112,43 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'DuplicateShelf', message: 'You have already added this shelf' },
|
{
|
||||||
{ status: 409 }
|
error: 'DuplicateShelf',
|
||||||
|
message: 'You have already added this shelf',
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate by fetching the RSS feed
|
// Validate by fetching the RSS feed
|
||||||
let shelfName: string;
|
let shelfName: string;
|
||||||
let bookCount: number;
|
let bookCount: number;
|
||||||
let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
|
let initialBooks: {
|
||||||
|
coverUrl: string;
|
||||||
|
asin: null;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
}[] = [];
|
||||||
try {
|
try {
|
||||||
const rssData = await fetchAndValidateRss(rssUrl);
|
const rssData = await fetchAndValidateRss(rssUrl);
|
||||||
shelfName = rssData.shelfName;
|
shelfName = rssData.shelfName;
|
||||||
bookCount = rssData.books.length;
|
bookCount = rssData.books.length;
|
||||||
initialBooks = rssData.books
|
initialBooks = rssData.books
|
||||||
.filter(b => b.coverUrl)
|
.filter((b) => b.coverUrl)
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
|
.map((b) => ({
|
||||||
|
coverUrl: b.coverUrl!,
|
||||||
|
asin: null,
|
||||||
|
title: b.title,
|
||||||
|
author: b.author,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'InvalidRSS',
|
error: 'InvalidRSS',
|
||||||
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,43 +158,55 @@ export async function POST(request: NextRequest) {
|
|||||||
name: shelfName,
|
name: shelfName,
|
||||||
rssUrl,
|
rssUrl,
|
||||||
bookCount,
|
bookCount,
|
||||||
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
coverUrls:
|
||||||
|
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
|
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
|
||||||
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
|
logger.info(
|
||||||
|
`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to trigger immediate shelf sync', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
success: true,
|
{
|
||||||
shelf: {
|
success: true,
|
||||||
id: shelf.id,
|
shelf: {
|
||||||
name: shelf.name,
|
id: shelf.id,
|
||||||
rssUrl: shelf.rssUrl,
|
name: shelf.name,
|
||||||
lastSyncAt: shelf.lastSyncAt,
|
rssUrl: shelf.rssUrl,
|
||||||
createdAt: shelf.createdAt,
|
lastSyncAt: shelf.lastSyncAt,
|
||||||
bookCount: shelf.bookCount,
|
createdAt: shelf.createdAt,
|
||||||
books: initialBooks,
|
bookCount: shelf.bookCount,
|
||||||
|
books: initialBooks,
|
||||||
|
},
|
||||||
|
bookCount,
|
||||||
},
|
},
|
||||||
bookCount,
|
{ status: 201 },
|
||||||
}, { status: 201 });
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to add shelf', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'ValidationError', details: error.errors },
|
{ error: 'ValidationError', details: error.errors },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 });
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to add shelf' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||||
try {
|
try {
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSyncHardcoverShelvesJob(undefined, shelf.id, 0);
|
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,8 +11,7 @@ import { RequestCard } from '@/components/requests/RequestCard';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useRequests } from '@/lib/hooks/useRequests';
|
import { useRequests } from '@/lib/hooks/useRequests';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
import { ShelvesSection } from '@/components/profile/ShelvesSection';
|
||||||
import { HardcoverShelvesSection } from '@/components/profile/HardcoverShelvesSection';
|
|
||||||
|
|
||||||
const statConfig = [
|
const statConfig = [
|
||||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||||
@@ -179,11 +178,8 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Goodreads Shelves */}
|
{/* Generic Shelves Section */}
|
||||||
<GoodreadsShelvesSection />
|
<ShelvesSection />
|
||||||
|
|
||||||
{/* Hardcover Lists */}
|
|
||||||
<HardcoverShelvesSection />
|
|
||||||
|
|
||||||
{/* Active Downloads */}
|
{/* Active Downloads */}
|
||||||
{activeDownloads.length > 0 && (
|
{activeDownloads.length > 0 && (
|
||||||
|
|||||||
@@ -1,360 +0,0 @@
|
|||||||
/**
|
|
||||||
* Component: Goodreads Shelves Section (Profile Page)
|
|
||||||
* Documentation: documentation/frontend/components.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
|
|
||||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return 'Never';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
if (diffMins < 1) return 'just now';
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
|
||||||
return `${diffDays}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GoodreadsShelvesSection() {
|
|
||||||
const { shelves, isLoading } = useGoodreadsShelves();
|
|
||||||
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf();
|
|
||||||
const { squareCovers } = usePreferences();
|
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
|
||||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleDelete = async (shelfId: string) => {
|
|
||||||
try {
|
|
||||||
await deleteShelf(shelfId);
|
|
||||||
setConfirmDeleteId(null);
|
|
||||||
} catch {
|
|
||||||
// Error handled by hook
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
|
|
||||||
Goodreads 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
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{isLoading ? (
|
|
||||||
<ShelfCardSkeleton squareCovers={squareCovers} />
|
|
||||||
) : shelves.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{shelves.map((shelf) => (
|
|
||||||
<ShelfCard
|
|
||||||
key={shelf.id}
|
|
||||||
shelf={shelf}
|
|
||||||
squareCovers={squareCovers}
|
|
||||||
isDeleting={isDeleting && confirmDeleteId === shelf.id}
|
|
||||||
isConfirmingDelete={confirmDeleteId === shelf.id}
|
|
||||||
onDelete={() => handleDelete(shelf.id)}
|
|
||||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
|
||||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
|
||||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState onAdd={() => setShowAddModal(true)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AddGoodreadsShelfModal
|
|
||||||
isOpen={showAddModal}
|
|
||||||
onClose={() => setShowAddModal(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Audiobook Detail Modal (read-only) */}
|
|
||||||
{selectedAsin && (
|
|
||||||
<AudiobookDetailsModal
|
|
||||||
asin={selectedAsin}
|
|
||||||
isOpen={true}
|
|
||||||
onClose={() => setSelectedAsin(null)}
|
|
||||||
hideRequestActions
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Empty State ─── */
|
|
||||||
|
|
||||||
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" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-200 mb-1.5">
|
|
||||||
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.
|
|
||||||
</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>
|
|
||||||
Add Your First Shelf
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Loading Skeleton ─── */
|
|
||||||
|
|
||||||
function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7">
|
|
||||||
<div className="mb-5">
|
|
||||||
<div className="h-[18px] w-52 bg-gray-100 dark:bg-gray-700/50 rounded-lg animate-pulse mb-2.5" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-[22px] w-16 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
|
|
||||||
<div className="h-3.5 w-24 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div
|
|
||||||
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]'
|
|
||||||
)}
|
|
||||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Shelf Card ─── */
|
|
||||||
|
|
||||||
interface ShelfCardProps {
|
|
||||||
shelf: GoodreadsShelf;
|
|
||||||
squareCovers: boolean;
|
|
||||||
isDeleting: boolean;
|
|
||||||
isConfirmingDelete: boolean;
|
|
||||||
onDelete: () => void;
|
|
||||||
onConfirmDelete: () => void;
|
|
||||||
onCancelDelete: () => void;
|
|
||||||
onBookClick: (asin: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShelfCard({
|
|
||||||
shelf,
|
|
||||||
squareCovers,
|
|
||||||
isDeleting,
|
|
||||||
isConfirmingDelete,
|
|
||||||
onDelete,
|
|
||||||
onConfirmDelete,
|
|
||||||
onCancelDelete,
|
|
||||||
onBookClick,
|
|
||||||
}: ShelfCardProps) {
|
|
||||||
const displayBooks = shelf.books.slice(0, 6);
|
|
||||||
const hasCovers = displayBooks.length > 0;
|
|
||||||
const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length);
|
|
||||||
const isSyncing = !shelf.lastSyncAt;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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="min-w-0 flex-1">
|
|
||||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
|
|
||||||
{shelf.name}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
{shelf.bookCount != null && (
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400 tabular-nums">
|
|
||||||
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
{isSyncing ? (
|
|
||||||
<>
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
|
||||||
</span>
|
|
||||||
Syncing…
|
|
||||||
</>
|
|
||||||
) : shelf.lastSyncAt ? (
|
|
||||||
<>
|
|
||||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
|
||||||
Synced {formatRelativeTime(shelf.lastSyncAt)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Pending sync'
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete action */}
|
|
||||||
<div className="flex-shrink-0 ml-4">
|
|
||||||
{isConfirmingDelete ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="px-3 py-1.5 text-xs font-semibold text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isDeleting ? 'Removing\u2026' : 'Remove'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onCancelDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="px-2 py-1.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom: Stacked book covers */}
|
|
||||||
{hasCovers ? (
|
|
||||||
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
|
|
||||||
) : isSyncing ? (
|
|
||||||
<div className="flex items-end">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div
|
|
||||||
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]'
|
|
||||||
)}
|
|
||||||
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Stacked Cover Display ─── */
|
|
||||||
|
|
||||||
function CoverStack({
|
|
||||||
books,
|
|
||||||
remainingCount,
|
|
||||||
squareCovers,
|
|
||||||
onBookClick,
|
|
||||||
}: {
|
|
||||||
books: ShelfBook[];
|
|
||||||
remainingCount: number;
|
|
||||||
squareCovers: boolean;
|
|
||||||
onBookClick: (asin: string) => void;
|
|
||||||
}) {
|
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
|
||||||
const coverSize = squareCovers
|
|
||||||
? 'w-[80px] aspect-square'
|
|
||||||
: 'w-[72px] aspect-[2/3]';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-end">
|
|
||||||
{books.map((book, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
'relative rounded-xl overflow-hidden shadow-md flex-shrink-0',
|
|
||||||
'ring-2 ring-white dark:ring-gray-800',
|
|
||||||
'transition-all duration-300 ease-out',
|
|
||||||
hoveredIndex === i && 'scale-[1.18] shadow-xl',
|
|
||||||
coverSize,
|
|
||||||
book.asin ? 'cursor-pointer' : 'cursor-default'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
marginLeft: i > 0 ? '-16px' : 0,
|
|
||||||
zIndex: hoveredIndex === i ? 50 : books.length - i,
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHoveredIndex(i)}
|
|
||||||
onMouseLeave={() => setHoveredIndex(null)}
|
|
||||||
onClick={() => book.asin && onBookClick(book.asin)}
|
|
||||||
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={book.coverUrl}
|
|
||||||
alt=""
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{remainingCount > 0 && (
|
|
||||||
<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
|
|
||||||
)}
|
|
||||||
style={{ marginLeft: '-16px', zIndex: 0 }}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold text-gray-400 dark:text-gray-500 tabular-nums">
|
|
||||||
+{remainingCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+145
-50
@@ -1,21 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Component: Hardcover Shelves Section (Profile Page)
|
* Component: Combined Shelves Section (Profile Page)
|
||||||
* Documentation: documentation/frontend/components.md
|
* Documentation: documentation/frontend/components.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
||||||
useHardcoverShelves,
|
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||||
useDeleteHardcoverShelf,
|
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||||
HardcoverShelf,
|
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||||
ShelfBook,
|
|
||||||
} from '@/lib/hooks/useHardcoverShelves';
|
|
||||||
import { AddHardcoverShelfModal } from '@/components/ui/AddHardcoverShelfModal';
|
import { AddHardcoverShelfModal } from '@/components/ui/AddHardcoverShelfModal';
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string | null): string {
|
function formatRelativeTime(dateStr: string | null): string {
|
||||||
if (!dateStr) return 'Never';
|
if (!dateStr) return 'Never';
|
||||||
@@ -31,31 +31,43 @@ function formatRelativeTime(dateStr: string | null): string {
|
|||||||
return `${diffDays}d ago`;
|
return `${diffDays}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HardcoverShelvesSection() {
|
export function ShelvesSection() {
|
||||||
const { shelves, isLoading } = useHardcoverShelves();
|
const { shelves, isLoading } = useShelves();
|
||||||
const { deleteShelf, isLoading: isDeleting } = useDeleteHardcoverShelf();
|
const { deleteShelf: deleteGoodreads, isLoading: isDeletingGoodreads } =
|
||||||
|
useDeleteGoodreadsShelf();
|
||||||
|
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
||||||
|
useDeleteHardcoverShelf();
|
||||||
const { squareCovers } = usePreferences();
|
const { squareCovers } = usePreferences();
|
||||||
|
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showProviderSelect, setShowProviderSelect] = useState(false);
|
||||||
|
const [showAddGoodreads, setShowAddGoodreads] = useState(false);
|
||||||
|
const [showAddHardcover, setShowAddHardcover] = useState(false);
|
||||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDelete = async (shelfId: string) => {
|
const handleDelete = async (shelf: GenericShelf) => {
|
||||||
try {
|
try {
|
||||||
await deleteShelf(shelfId);
|
if (shelf.type === 'goodreads') {
|
||||||
|
await deleteGoodreads(shelf.id);
|
||||||
|
} else {
|
||||||
|
await deleteHardcover(shelf.id);
|
||||||
|
}
|
||||||
setConfirmDeleteId(null);
|
setConfirmDeleteId(null);
|
||||||
} catch {
|
} catch {
|
||||||
// Error handled by hook
|
// Error handled by hook
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 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">
|
<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
|
<svg
|
||||||
className="w-[18px] h-[18px] text-indigo-600 dark:text-indigo-400"
|
className="w-[18px] h-[18px] text-emerald-600 dark:text-emerald-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -70,36 +82,38 @@ export function HardcoverShelvesSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
|
||||||
Hardcover Lists
|
Shelves
|
||||||
</h2>
|
</h2>
|
||||||
{!isLoading && shelves.length > 0 && (
|
{!isLoading && shelves.length > 0 && (
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
{shelves.length} {shelves.length === 1 ? 'list' : 'lists'}{' '}
|
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'}{' '}
|
||||||
connected
|
connected
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{shelves.length > 0 && (
|
||||||
onClick={() => setShowAddModal(true)}
|
<button
|
||||||
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"
|
onClick={() => setShowProviderSelect(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
|
<svg
|
||||||
strokeLinecap="round"
|
className="w-4 h-4"
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
d="M12 4.5v15m7.5-7.5h-15"
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
strokeWidth={2}
|
||||||
Add List
|
>
|
||||||
</button>
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 4.5v15m7.5-7.5h-15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Add Shelf
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -114,7 +128,7 @@ export function HardcoverShelvesSection() {
|
|||||||
squareCovers={squareCovers}
|
squareCovers={squareCovers}
|
||||||
isDeleting={isDeleting && confirmDeleteId === shelf.id}
|
isDeleting={isDeleting && confirmDeleteId === shelf.id}
|
||||||
isConfirmingDelete={confirmDeleteId === shelf.id}
|
isConfirmingDelete={confirmDeleteId === shelf.id}
|
||||||
onDelete={() => handleDelete(shelf.id)}
|
onDelete={() => handleDelete(shelf)}
|
||||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||||
@@ -122,15 +136,30 @@ export function HardcoverShelvesSection() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState onAdd={() => setShowAddModal(true)} />
|
<EmptyState onAdd={() => setShowProviderSelect(true)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AddHardcoverShelfModal
|
{/* Modals */}
|
||||||
isOpen={showAddModal}
|
<ProviderSelectModal
|
||||||
onClose={() => setShowAddModal(false)}
|
isOpen={showProviderSelect}
|
||||||
|
onClose={() => setShowProviderSelect(false)}
|
||||||
|
onSelect={(provider) => {
|
||||||
|
setShowProviderSelect(false);
|
||||||
|
if (provider === 'goodreads') setShowAddGoodreads(true);
|
||||||
|
else if (provider === 'hardcover') setShowAddHardcover(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddGoodreadsShelfModal
|
||||||
|
isOpen={showAddGoodreads}
|
||||||
|
onClose={() => setShowAddGoodreads(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddHardcoverShelfModal
|
||||||
|
isOpen={showAddHardcover}
|
||||||
|
onClose={() => setShowAddHardcover(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Audiobook Detail Modal (read-only) */}
|
|
||||||
{selectedAsin && (
|
{selectedAsin && (
|
||||||
<AudiobookDetailsModal
|
<AudiobookDetailsModal
|
||||||
asin={selectedAsin}
|
asin={selectedAsin}
|
||||||
@@ -143,14 +172,69 @@ export function HardcoverShelvesSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Provider Select Modal ─── */
|
||||||
|
|
||||||
|
function ProviderSelectModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (provider: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Select Provider" size="sm">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('goodreads')}
|
||||||
|
className="w-full flex items-center gap-4 p-4 text-left bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-amber-400 dark:hover:border-amber-500 hover:bg-amber-50 dark:hover:bg-amber-500/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-500/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-amber-700 dark:text-amber-400 font-bold text-lg">
|
||||||
|
g
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Goodreads
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Connect via RSS feed URL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('hardcover')}
|
||||||
|
className="w-full flex items-center gap-4 p-4 text-left bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-500/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-indigo-700 dark:text-indigo-400 font-bold text-lg">
|
||||||
|
H
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
Hardcover
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Connect via API token and List ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Empty State ─── */
|
/* ─── Empty State ─── */
|
||||||
|
|
||||||
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
function EmptyState({ onAdd }: { onAdd: () => void }) {
|
||||||
return (
|
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="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-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center mb-5 ring-1 ring-indigo-200/50 dark:ring-indigo-500/10">
|
<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
|
<svg
|
||||||
className="w-7 h-7 text-indigo-500 dark:text-indigo-400"
|
className="w-7 h-7 text-emerald-500 dark:text-emerald-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -168,8 +252,8 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
|
|||||||
Connect your reading list
|
Connect your reading list
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
|
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
|
||||||
Link a Hardcover list and we'll automatically request the audiobook for
|
Link a Goodreads or Hardcover shelf and we'll automatically request the
|
||||||
every book you add.
|
audiobook for every book you add.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -189,7 +273,7 @@ function EmptyState({ onAdd }: { onAdd: () => void }) {
|
|||||||
d="M12 4.5v15m7.5-7.5h-15"
|
d="M12 4.5v15m7.5-7.5h-15"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Add Your First List
|
Add Your First Shelf
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -226,7 +310,7 @@ function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
|
|||||||
/* ─── Shelf Card ─── */
|
/* ─── Shelf Card ─── */
|
||||||
|
|
||||||
interface ShelfCardProps {
|
interface ShelfCardProps {
|
||||||
shelf: HardcoverShelf;
|
shelf: GenericShelf;
|
||||||
squareCovers: boolean;
|
squareCovers: boolean;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
isConfirmingDelete: boolean;
|
isConfirmingDelete: boolean;
|
||||||
@@ -254,6 +338,17 @@ function ShelfCard({
|
|||||||
);
|
);
|
||||||
const isSyncing = !shelf.lastSyncAt;
|
const isSyncing = !shelf.lastSyncAt;
|
||||||
|
|
||||||
|
const providerIcon =
|
||||||
|
shelf.type === 'goodreads' ? (
|
||||||
|
<span className="text-amber-600 dark:text-amber-400 font-bold ml-2">
|
||||||
|
g
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-indigo-600 dark:text-indigo-400 font-bold ml-2">
|
||||||
|
H
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* Top: Shelf info + actions */}
|
||||||
@@ -264,8 +359,8 @@ function ShelfCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
|
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
|
||||||
{shelf.name}
|
{shelf.name} {providerIcon}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
{shelf.bookCount != null && (
|
{shelf.bookCount != null && (
|
||||||
@@ -317,7 +412,7 @@ function ShelfCard({
|
|||||||
<button
|
<button
|
||||||
onClick={onConfirmDelete}
|
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"
|
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 list"
|
title="Remove shelf"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-[18px] h-[18px]"
|
className="w-[18px] h-[18px]"
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Component: Sync Hardcover Shelves Processor
|
|
||||||
* Documentation: documentation/backend/services/scheduler.md
|
|
||||||
*
|
|
||||||
* Dedicated processor for syncing Hardcover lists.
|
|
||||||
* Resolves books to Audible ASINs and creates requests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RMABLogger } from '../utils/logger';
|
|
||||||
|
|
||||||
export interface SyncHardcoverShelvesPayload {
|
|
||||||
jobId?: string;
|
|
||||||
scheduledJobId?: string;
|
|
||||||
/** If set, only process this specific list (used for immediate sync on add) */
|
|
||||||
shelfId?: string;
|
|
||||||
/** Max Audible lookups per list. 0 = unlimited. */
|
|
||||||
maxLookupsPerShelf?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processSyncHardcoverShelves(
|
|
||||||
payload: SyncHardcoverShelvesPayload,
|
|
||||||
): Promise<any> {
|
|
||||||
const { jobId, shelfId, maxLookupsPerShelf } = payload;
|
|
||||||
const logger = RMABLogger.forJob(jobId, 'SyncHardcoverShelves');
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
shelfId
|
|
||||||
? `Starting immediate Hardcover sync for list ${shelfId}...`
|
|
||||||
: 'Starting scheduled Hardcover lists sync...',
|
|
||||||
);
|
|
||||||
|
|
||||||
const { processHardcoverShelves } =
|
|
||||||
await import('../services/hardcover-sync.service');
|
|
||||||
const stats = await processHardcoverShelves(logger, {
|
|
||||||
shelfId,
|
|
||||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('Hardcover sync complete', { stats });
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: shelfId ? 'Hardcover list synced' : 'Hardcover lists 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,8 +26,7 @@ export type JobType =
|
|||||||
| 'retry_failed_imports'
|
| 'retry_failed_imports'
|
||||||
| 'cleanup_seeded_torrents'
|
| 'cleanup_seeded_torrents'
|
||||||
| 'monitor_rss_feeds'
|
| 'monitor_rss_feeds'
|
||||||
| 'sync_goodreads_shelves'
|
| 'sync_reading_shelves'
|
||||||
| 'sync_hardcover_shelves'
|
|
||||||
| 'send_notification'
|
| 'send_notification'
|
||||||
// Ebook-specific job types
|
// Ebook-specific job types
|
||||||
| 'search_ebook'
|
| 'search_ebook'
|
||||||
@@ -106,15 +105,10 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
|
|||||||
scheduledJobId?: string;
|
scheduledJobId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
export interface SyncShelvesPayload extends JobPayload {
|
||||||
scheduledJobId?: string;
|
|
||||||
shelfId?: string;
|
|
||||||
maxLookupsPerShelf?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SyncHardcoverShelvesPayload extends JobPayload {
|
|
||||||
scheduledJobId?: string;
|
scheduledJobId?: string;
|
||||||
shelfId?: string;
|
shelfId?: string;
|
||||||
|
shelfType?: 'goodreads' | 'hardcover';
|
||||||
maxLookupsPerShelf?: number;
|
maxLookupsPerShelf?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,30 +441,16 @@ export class JobQueueService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.queue.process(
|
this.queue.process(
|
||||||
'sync_goodreads_shelves',
|
'sync_reading_shelves',
|
||||||
1,
|
1,
|
||||||
async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
|
async (job: BullJob<SyncShelvesPayload>) => {
|
||||||
const { processSyncGoodreadsShelves } =
|
const { processSyncShelves } =
|
||||||
await import('../processors/sync-goodreads-shelves.processor');
|
await import('../processors/sync-shelves.processor');
|
||||||
const payloadWithJobId = await this.ensureJobRecord(
|
const payloadWithJobId = await this.ensureJobRecord(
|
||||||
job,
|
job,
|
||||||
'sync_goodreads_shelves',
|
'sync_reading_shelves',
|
||||||
);
|
);
|
||||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
return await processSyncShelves(payloadWithJobId);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.queue.process(
|
|
||||||
'sync_hardcover_shelves',
|
|
||||||
1,
|
|
||||||
async (job: BullJob<SyncHardcoverShelvesPayload>) => {
|
|
||||||
const { processSyncHardcoverShelves } =
|
|
||||||
await import('../processors/sync-hardcover-shelves.processor');
|
|
||||||
const payloadWithJobId = await this.ensureJobRecord(
|
|
||||||
job,
|
|
||||||
'sync_hardcover_shelves',
|
|
||||||
);
|
|
||||||
return await processSyncHardcoverShelves(payloadWithJobId);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -875,41 +855,22 @@ export class JobQueueService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add sync Goodreads shelves job
|
* Add sync reading shelves job
|
||||||
*/
|
*/
|
||||||
async addSyncGoodreadsShelvesJob(
|
async addSyncShelvesJob(
|
||||||
scheduledJobId?: string,
|
scheduledJobId?: string,
|
||||||
shelfId?: string,
|
shelfId?: string,
|
||||||
|
shelfType?: 'goodreads' | 'hardcover',
|
||||||
maxLookupsPerShelf?: number,
|
maxLookupsPerShelf?: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'sync_goodreads_shelves',
|
'sync_reading_shelves',
|
||||||
{
|
{
|
||||||
scheduledJobId,
|
scheduledJobId,
|
||||||
shelfId,
|
shelfId,
|
||||||
|
shelfType,
|
||||||
maxLookupsPerShelf,
|
maxLookupsPerShelf,
|
||||||
} as SyncGoodreadsShelvesPayload,
|
} as SyncShelvesPayload,
|
||||||
{
|
|
||||||
priority: 7,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add sync Hardcover shelves job
|
|
||||||
*/
|
|
||||||
async addSyncHardcoverShelvesJob(
|
|
||||||
scheduledJobId?: string,
|
|
||||||
shelfId?: string,
|
|
||||||
maxLookupsPerShelf?: number,
|
|
||||||
): Promise<string> {
|
|
||||||
return await this.addJob(
|
|
||||||
'sync_hardcover_shelves',
|
|
||||||
{
|
|
||||||
scheduledJobId,
|
|
||||||
shelfId,
|
|
||||||
maxLookupsPerShelf,
|
|
||||||
} as SyncHardcoverShelvesPayload,
|
|
||||||
{
|
{
|
||||||
priority: 7,
|
priority: 7,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ export type ScheduledJobType =
|
|||||||
| 'retry_failed_imports'
|
| 'retry_failed_imports'
|
||||||
| 'cleanup_seeded_torrents'
|
| 'cleanup_seeded_torrents'
|
||||||
| 'monitor_rss_feeds'
|
| 'monitor_rss_feeds'
|
||||||
| 'sync_goodreads_shelves'
|
| 'sync_reading_shelves';
|
||||||
| 'sync_hardcover_shelves';
|
|
||||||
|
|
||||||
export interface ScheduledJob {
|
export interface ScheduledJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,6 +67,9 @@ export class SchedulerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up deprecated scheduled jobs
|
||||||
|
await this.cleanupDeprecatedJobs();
|
||||||
|
|
||||||
// Create default jobs if they don't exist
|
// Create default jobs if they don't exist
|
||||||
await this.ensureDefaultJobs();
|
await this.ensureDefaultJobs();
|
||||||
|
|
||||||
@@ -136,15 +138,8 @@ export class SchedulerService {
|
|||||||
payload: {},
|
payload: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Sync Goodreads Shelves',
|
name: 'Sync Reading Shelves',
|
||||||
type: 'sync_goodreads_shelves' as ScheduledJobType,
|
type: 'sync_reading_shelves' as ScheduledJobType,
|
||||||
schedule: '0 */6 * * *', // Every 6 hours
|
|
||||||
enabled: true, // Enable by default
|
|
||||||
payload: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Sync Hardcover Lists',
|
|
||||||
type: 'sync_hardcover_shelves' as ScheduledJobType,
|
|
||||||
schedule: '0 */6 * * *', // Every 6 hours
|
schedule: '0 */6 * * *', // Every 6 hours
|
||||||
enabled: true, // Enable by default
|
enabled: true, // Enable by default
|
||||||
payload: {},
|
payload: {},
|
||||||
@@ -187,6 +182,36 @@ export class SchedulerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove any old jobs that are no longer supported
|
||||||
|
*/
|
||||||
|
private async cleanupDeprecatedJobs(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const deprecatedTypes = [
|
||||||
|
'sync_goodreads_shelves',
|
||||||
|
'sync_hardcover_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
|
* Schedule all enabled jobs
|
||||||
*/
|
*/
|
||||||
@@ -374,11 +399,8 @@ export class SchedulerService {
|
|||||||
case 'monitor_rss_feeds':
|
case 'monitor_rss_feeds':
|
||||||
bullJobId = await this.triggerMonitorRssFeeds(job);
|
bullJobId = await this.triggerMonitorRssFeeds(job);
|
||||||
break;
|
break;
|
||||||
case 'sync_goodreads_shelves':
|
case 'sync_reading_shelves':
|
||||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
bullJobId = await this.triggerSyncShelves(job);
|
||||||
break;
|
|
||||||
case 'sync_hardcover_shelves':
|
|
||||||
bullJobId = await this.triggerSyncHardcoverShelves(job);
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown job type: ${job.type}`);
|
throw new Error(`Unknown job type: ${job.type}`);
|
||||||
@@ -663,17 +685,10 @@ export class SchedulerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger Goodreads shelves sync
|
* Trigger Reading shelves sync
|
||||||
*/
|
*/
|
||||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
private async triggerSyncShelves(job: any): Promise<string> {
|
||||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
return await this.jobQueue.addSyncShelvesJob(job.id);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger Hardcover lists sync
|
|
||||||
*/
|
|
||||||
private async triggerSyncHardcoverShelves(job: any): Promise<string> {
|
|
||||||
return await this.jobQueue.addSyncHardcoverShelvesJob(job.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user