Merge pull request #126 from brombomb/hardcover-api

Unified Reading Shelves & Hardcover Integration
This commit is contained in:
kikootwo
2026-03-03 22:13:32 -05:00
committed by GitHub
31 changed files with 2987 additions and 315 deletions
+51 -20
View File
@@ -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>
);
@@ -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&apos;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 &rarr; select a shelf &rarr; 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>
);
}
+366
View File
@@ -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 &rarr; select a shelf &rarr; 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>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import React, { useState } from 'react';
import { Modal } from './Modal';
import { GenericShelf } from '@/lib/hooks/useShelves';
import { useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
import { useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
import { cn } from '@/lib/utils/cn';
interface ManageShelfModalProps {
shelf: GenericShelf | null;
isOpen: boolean;
onClose: () => void;
}
export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalProps) {
const [rssUrl, setRssUrl] = useState(shelf?.type === 'goodreads' ? shelf.sourceId : '');
const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : '');
const [apiToken, setApiToken] = useState('');
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads } = useUpdateGoodreadsShelf();
const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover } = useUpdateHardcoverShelf();
// Reset form when shelf changes
React.useEffect(() => {
if (shelf) {
setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : '');
setListId(shelf.type === 'hardcover' ? shelf.sourceId : '');
setApiToken('');
}
}, [shelf]);
if (!shelf) return null;
const isUpdating = isUpdatingGoodreads || isUpdatingHardcover;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (shelf.type === 'goodreads') {
if (!rssUrl.trim()) return;
await updateGoodreads(shelf.id, rssUrl.trim());
} else {
if (!listId.trim()) return;
await updateHardcover(shelf.id, {
listId: listId.trim(),
apiToken: apiToken.trim() || undefined,
});
}
onClose();
} catch (err) {
// Error is handled by hook
}
};
const isGoodreads = shelf.type === 'goodreads';
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Manage ${shelf.name}`}>
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-5">
{isGoodreads ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Goodreads RSS URL
</label>
<input
type="url"
required
value={rssUrl}
onChange={(e) => setRssUrl(e.target.value)}
placeholder="https://www.goodreads.com/review/list_rss/..."
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 dark:focus:ring-emerald-400 dark:text-white transition-colors"
disabled={isUpdating}
/>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hardcover List ID or Slug
</label>
<input
type="text"
required
value={listId}
onChange={(e) => setListId(e.target.value)}
placeholder="e.g., 1234, want-to-read, status-1"
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors"
disabled={isUpdating}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New API Token <span className="text-gray-400 dark:text-gray-500 font-normal">(Leave blank to keep current)</span>
</label>
<input
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder="Paste your Hardcover token here..."
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:focus:ring-indigo-400 dark:text-white transition-colors"
disabled={isUpdating}
/>
</div>
</>
)}
<div className="flex gap-3 justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
disabled={isUpdating}
>
Cancel
</button>
<button
type="submit"
disabled={isUpdating}
className={cn(
'px-6 py-2 text-sm font-medium text-white rounded-xl shadow-sm transition-colors',
isGoodreads
? 'bg-amber-600 hover:bg-amber-700'
: 'bg-indigo-600 hover:bg-indigo-700',
isUpdating && 'opacity-50 cursor-not-allowed',
)}
>
{isUpdating ? 'Saving...' : 'Update & Re-sync'}
</button>
</div>
</form>
</div>
</Modal>
);
}
+14 -5
View File
@@ -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);
}