Add Hardcover shelf sync & unify book mappings

Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers.
This commit is contained in:
kikootwo
2026-03-04 10:11:19 -05:00
parent 6ca2e964e8
commit 338331d006
23 changed files with 1613 additions and 1391 deletions
+4 -2
View File
@@ -354,8 +354,9 @@ function ShelfCard({
<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"
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
title="Manage shelf"
aria-label="Manage shelf"
>
<svg
className="w-[18px] h-[18px]"
@@ -373,8 +374,9 @@ function ShelfCard({
</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"
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
title="Remove shelf"
aria-label="Remove shelf"
>
<svg
className="w-[18px] h-[18px]"
+47 -179
View File
@@ -11,6 +11,7 @@ import { Input } from './Input';
import { Button } from './Button';
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
import { HardcoverForm } from './HardcoverForm';
interface AddShelfModalProps {
isOpen: boolean;
@@ -20,9 +21,7 @@ interface AddShelfModalProps {
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
const [provider, setProvider] = useState<'goodreads' | 'hardcover'>(
'goodreads',
);
const [provider, setProvider] = useState<'goodreads' | 'hardcover'>('goodreads');
// Goodreads State
const [rssUrl, setRssUrl] = useState('');
@@ -30,27 +29,18 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
// Hardcover State
const [apiToken, setApiToken] = useState('');
const [listType, setListType] = useState<'status' | 'custom'>('status');
const [statusId, setStatusId] = useState('1'); // 1 = Want to Read
const [statusId, setStatusId] = useState('1');
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 { 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 currentError = provider === 'goodreads' ? goodreadsError : hardcoverError;
const validateInput = (): boolean => {
if (provider === 'goodreads') {
@@ -59,9 +49,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
return false;
}
if (!GOODREADS_RSS_PATTERN.test(rssUrl)) {
setValidationError(
'Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)',
);
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
return false;
}
} else {
@@ -88,8 +76,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
setRssUrl('');
} else {
const finalId =
listType === 'status' ? `status-${statusId}` : customListId.trim();
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
let cleanedToken = apiToken.trim();
if (cleanedToken.toLowerCase().startsWith('bearer ')) {
cleanedToken = cleanedToken.slice(7).trim();
@@ -124,7 +111,8 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add Shelf" size="sm">
<div className="space-y-5">
{/* Provider Selection Tabs */}
{/* Provider Tabs */}
<div className="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
type="button"
@@ -133,10 +121,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
? '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('');
}}
onClick={() => { setProvider('goodreads'); setValidationError(''); }}
>
Goodreads
</button>
@@ -147,97 +132,56 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
? '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('');
}}
onClick={() => { setProvider('hardcover'); setValidationError(''); }}
>
Hardcover
</button>
</div>
{/* Visual header */}
{/* 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>
<img src="/goodreads-icon.png" alt="Goodreads" className="w-5 h-5 object-contain" />
</div>
<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 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>
<img src="/hardcover-icon.svg" alt="Hardcover" className="w-6 h-6 object-contain" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
Connect a Hardcover reading list and books will be automatically requested as you add them.
</p>
</>
)}
</div>
{/* Success alert */}
{/* 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 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>
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
</div>
)}
{/* Error alert */}
{/* 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 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>
<p className="text-sm font-medium text-red-700 dark:text-red-300">{currentError}</p>
</div>
)}
@@ -249,113 +193,37 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
type="url"
label="Goodreads RSS URL"
value={rssUrl}
onChange={(e) => {
setRssUrl(e.target.value);
if (validationError) setValidationError('');
}}
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.
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>
<HardcoverForm
apiToken={apiToken}
setApiToken={setApiToken}
listType={listType}
setListType={setListType}
statusId={statusId}
setStatusId={setStatusId}
customListId={customListId}
setCustomListId={setCustomListId}
validationError={validationError}
setValidationError={setValidationError}
isLoading={isLoading}
success={success}
/>
)}
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
disabled={isLoading || success}
>
<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}
>
<Button type="submit" variant="primary" size="sm" loading={isLoading} disabled={isLoading || success}>
Add Shelf
</Button>
</div>
+318
View File
@@ -0,0 +1,318 @@
/**
* Component: Hardcover Shelf Form
* Documentation: documentation/frontend/components.md
*/
'use client';
import React from 'react';
import { Input } from './Input';
// ---------------------------------------------------------------------------
// Status option definitions
// ---------------------------------------------------------------------------
const STATUS_OPTIONS = [
{
id: '1',
label: 'Want to Read',
description: 'Books saved to read later',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
</svg>
),
},
{
id: '2',
label: 'Currently Reading',
description: 'Books actively being read',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
),
},
{
id: '3',
label: 'Read',
description: 'Books already finished',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
),
},
{
id: '4',
label: 'Did Not Finish',
description: 'Books started but set aside',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
),
},
] as const;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface HardcoverFormProps {
apiToken: string;
setApiToken: (v: string) => void;
listType: 'status' | 'custom';
setListType: (v: 'status' | 'custom') => void;
statusId: string;
setStatusId: (v: string) => void;
customListId: string;
setCustomListId: (v: string) => void;
validationError: string;
setValidationError: (v: string) => void;
isLoading: boolean;
success: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function HardcoverForm({
apiToken, setApiToken,
listType, setListType,
statusId, setStatusId,
customListId, setCustomListId,
validationError, setValidationError,
isLoading, success,
}: HardcoverFormProps) {
const disabled = isLoading || success;
const isTokenError = validationError === 'Hardcover API Token is required';
const isListError = !isTokenError && !!validationError;
return (
<div className="space-y-5">
{/* API Token */}
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
API Token
</label>
<a
href="https://hardcover.app/account/api"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition-colors flex items-center gap-1 group"
>
Get your token
<svg className="w-3 h-3 opacity-60 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</div>
<input
type="password"
value={apiToken}
onChange={(e) => {
setApiToken(e.target.value);
if (isTokenError) setValidationError('');
}}
placeholder="Paste your Hardcover API token"
disabled={disabled}
className={[
'block w-full rounded-lg border px-4 py-2 text-sm transition-colors',
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60',
'disabled:opacity-50 disabled:cursor-not-allowed',
'bg-white dark:bg-gray-800/60 text-gray-900 dark:text-white',
'placeholder-gray-400 dark:placeholder-gray-500',
isTokenError
? 'border-red-400 dark:border-red-500'
: 'border-gray-200 dark:border-gray-700',
].join(' ')}
/>
{isTokenError && (
<p className="text-xs text-red-500 dark:text-red-400">{validationError}</p>
)}
<p className="text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
Found under{' '}
<span className="font-medium text-gray-500 dark:text-gray-400">Settings &rarr; API</span>
{' '}on hardcover.app. Stored securely and never shared.
</p>
</div>
{/* Divider */}
<div className="border-t border-gray-100 dark:border-gray-700/60" />
{/* List Type Selection */}
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Which list should we watch?
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Choose a reading status or one of your custom lists.
</p>
</div>
<div className="grid grid-cols-2 gap-2.5">
<ListTypeCard
active={listType === 'status'}
onClick={() => setListType('status')}
disabled={disabled}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />
</svg>
}
title="Reading Status"
subtitle="Want to Read, Reading, Read, etc."
/>
<ListTypeCard
active={listType === 'custom'}
onClick={() => setListType('custom')}
disabled={disabled}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
}
title="Custom List"
subtitle="A list you created on Hardcover"
/>
</div>
</div>
{/* Status picker or Custom list input */}
{listType === 'status' ? (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Status to sync</p>
<div className="space-y-1.5">
{STATUS_OPTIONS.map((opt) => (
<StatusRow
key={opt.id}
opt={opt}
selected={statusId === opt.id}
onSelect={() => setStatusId(opt.id)}
disabled={disabled}
/>
))}
</div>
</div>
) : (
<div className="space-y-2">
<Input
type="text"
label="List URL or Slug"
value={customListId}
onChange={(e) => {
setCustomListId(e.target.value);
if (isListError) setValidationError('');
}}
placeholder="https://hardcover.app/@username/lists/..."
error={isListError ? validationError : ''}
disabled={disabled}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
Paste the list URL from Hardcover, or enter just the slug (e.g.{' '}
<code className="font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700/60 px-1 py-0.5 rounded text-[11px]">my-audiobooks</code>
) or a numeric ID.
</p>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function ListTypeCard({
active, onClick, disabled, icon, title, subtitle,
}: {
active: boolean;
onClick: () => void;
disabled: boolean;
icon: React.ReactNode;
title: string;
subtitle: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={[
'relative text-left p-3 rounded-xl border-2 transition-all duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
active
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50/70 dark:bg-indigo-500/[0.08]'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800/60',
].join(' ')}
>
{active && (
<span className="absolute top-2.5 right-2.5 w-2 h-2 rounded-full bg-indigo-500 dark:bg-indigo-400" />
)}
<div className={[
'w-7 h-7 rounded-lg flex items-center justify-center mb-2',
active
? 'bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
].join(' ')}>
{icon}
</div>
<p className={`text-sm font-medium leading-tight ${active ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
{title}
</p>
<p className={`text-xs mt-0.5 leading-snug ${active ? 'text-indigo-500/80 dark:text-indigo-400/70' : 'text-gray-400 dark:text-gray-500'}`}>
{subtitle}
</p>
</button>
);
}
function StatusRow({
opt, selected, onSelect, disabled,
}: {
opt: typeof STATUS_OPTIONS[number];
selected: boolean;
onSelect: () => void;
disabled: boolean;
}) {
return (
<button
type="button"
onClick={onSelect}
disabled={disabled}
className={[
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-150 text-left',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1',
'disabled:opacity-50 disabled:cursor-not-allowed',
selected
? 'border-indigo-400/70 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-500/[0.08]'
: 'border-gray-200 dark:border-gray-700/80 bg-white dark:bg-gray-800/30 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/80 dark:hover:bg-gray-800/50',
].join(' ')}
>
<span className={`flex-shrink-0 ${selected ? 'text-indigo-500 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500'}`}>
{opt.icon}
</span>
<span className="flex-1 min-w-0">
<span className={`block text-sm font-medium ${selected ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
{opt.label}
</span>
<span className="block text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{opt.description}
</span>
</span>
{selected && (
<span className="flex-shrink-0">
<svg className="w-4 h-4 text-indigo-500 dark:text-indigo-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
</svg>
</span>
)}
</button>
);
}
+19 -2
View File
@@ -1,3 +1,8 @@
/**
* Component: Manage Shelf Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
@@ -18,8 +23,8 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
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();
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf();
const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf();
// Reset form when shelf changes
React.useEffect(() => {
@@ -33,6 +38,7 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
if (!shelf) return null;
const isUpdating = isUpdatingGoodreads || isUpdatingHardcover;
const currentError = shelf.type === 'goodreads' ? goodreadsError : hardcoverError;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -58,6 +64,17 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Manage ${shelf.name}`}>
<div className="space-y-6">
{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 text-red-700 dark:text-red-300">{currentError}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{isGoodreads ? (
<div>