mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
98c89db0a7
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.
260 lines
11 KiB
TypeScript
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 → select a shelf → 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>
|
|
);
|
|
}
|