Files
ReadMeABook/src/components/ui/AddShelfModal.tsx
T
kikootwo 98c89db0a7 Add per-shelf autoRequest toggle
Introduce an autoRequest boolean on Goodreads and Hardcover shelves (default true) so users can pause/resume automatic request creation. Schema, API handlers, hooks and types were updated to accept and persist autoRequest when creating or updating shelves; add endpoints only trigger an immediate resync when the feed/token changes. The shelf sync core and service code now respect autoRequest (skipping request creation and annotating logs when disabled). UI updates include an AddShelf toggle, manage/update payload changes, shelf list props, and visual indicators + toggle actions in the shelf cards.
2026-03-11 09:55:00 -04:00

260 lines
11 KiB
TypeScript

/**
* 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';
import { HardcoverForm } from './HardcoverForm';
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');
const [customListId, setCustomListId] = useState('');
// Shared State
const [autoRequest, setAutoRequest] = useState(true);
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, autoRequest);
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
setRssUrl('');
} else {
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
const shelf = await addHardcover(apiToken.trim(), finalId, autoRequest);
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('');
setAutoRequest(true);
setValidationError('');
setSuccess(false);
setSuccessMessage('');
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add Shelf" size="sm">
<div className="space-y-5">
{/* Provider 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>
<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>
<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 && (
<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>
) : (
<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}
/>
)}
{/* Auto-Request Toggle */}
<label className="flex items-center justify-between gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/30 cursor-pointer select-none">
<div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Auto-request books</span>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Automatically request audiobooks from this shelf
</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoRequest}
onClick={() => setAutoRequest(!autoRequest)}
disabled={isLoading || success}
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 ${
autoRequest ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
} ${(isLoading || success) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
autoRequest ? 'translate-x-4' : 'translate-x-0.5'
} mt-0.5`}
/>
</button>
</label>
<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>
);
}