mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Better UX for Custom Lists
This commit is contained in:
@@ -95,7 +95,13 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { listId, apiToken } = AddShelfSchema.parse(body);
|
let { listId, apiToken } = AddShelfSchema.parse(body);
|
||||||
|
|
||||||
|
// Clean up token in case user pasted "Bearer " prefix
|
||||||
|
apiToken = apiToken.trim();
|
||||||
|
if (apiToken.toLowerCase().startsWith('bearer ')) {
|
||||||
|
apiToken = apiToken.slice(7).trim();
|
||||||
|
}
|
||||||
|
|
||||||
// Check for duplicate
|
// Check for duplicate
|
||||||
const existing = await prisma.hardcoverShelf.findUnique({
|
const existing = await prisma.hardcoverShelf.findUnique({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { VersionBadge } from '@/components/ui/VersionBadge';
|
import { VersionBadge } from '@/components/ui/VersionBadge';
|
||||||
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
|
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
|
||||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
@@ -21,8 +21,9 @@ export function Header() {
|
|||||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||||
const [showBookDate, setShowBookDate] = useState(false);
|
const [showBookDate, setShowBookDate] = useState(false);
|
||||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||||
const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false);
|
const [showAddShelfModal, setShowAddShelfModal] = useState(false);
|
||||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
|
const { containerRef, dropdownRef, positionAbove, style } =
|
||||||
|
useSmartDropdownPosition(showUserMenu);
|
||||||
|
|
||||||
// Check if user can change password (local users only)
|
// Check if user can change password (local users only)
|
||||||
const canChangePassword = user?.authProvider === 'local';
|
const canChangePassword = user?.authProvider === 'local';
|
||||||
@@ -44,16 +45,14 @@ export function Header() {
|
|||||||
|
|
||||||
const response = await fetch('/api/bookdate/config', {
|
const response = await fetch('/api/bookdate/config', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// Show BookDate to any user with verified and enabled configuration
|
// Show BookDate to any user with verified and enabled configuration
|
||||||
setShowBookDate(
|
setShowBookDate(
|
||||||
data.config &&
|
data.config && data.config.isVerified && data.config.isEnabled,
|
||||||
data.config.isVerified &&
|
|
||||||
data.config.isEnabled
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check BookDate config:', error);
|
console.error('Failed to check BookDate config:', error);
|
||||||
@@ -95,11 +94,11 @@ export function Header() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowUserMenu(false);
|
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"
|
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>
|
</button>
|
||||||
{canChangePassword && (
|
{canChangePassword && (
|
||||||
<button
|
<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"
|
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"
|
aria-label="Search"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
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>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -218,12 +227,32 @@ export function Header() {
|
|||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
{showMobileMenu ? (
|
{showMobileMenu ? (
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
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>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -327,7 +356,9 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User menu dropdown (rendered via portal) */}
|
{/* 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 */}
|
{/* Change Password Modal */}
|
||||||
<ChangePasswordModal
|
<ChangePasswordModal
|
||||||
@@ -335,10 +366,10 @@ export function Header() {
|
|||||||
onClose={() => setShowChangePasswordModal(false)}
|
onClose={() => setShowChangePasswordModal(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add Goodreads Shelf Modal */}
|
{/* Add Shelf Modal */}
|
||||||
<AddGoodreadsShelfModal
|
<AddShelfModal
|
||||||
isOpen={showAddGoodreadsModal}
|
isOpen={showAddShelfModal}
|
||||||
onClose={() => setShowAddGoodreadsModal(false)}
|
onClose={() => setShowAddShelfModal(false)}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import React, { useState } from 'react';
|
|||||||
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
||||||
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||||
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||||
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';
|
||||||
@@ -40,9 +39,7 @@ export function ShelvesSection() {
|
|||||||
const { squareCovers } = usePreferences();
|
const { squareCovers } = usePreferences();
|
||||||
|
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [showProviderSelect, setShowProviderSelect] = useState(false);
|
const [showAddShelf, setShowAddShelf] = 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 (shelf: GenericShelf) => {
|
const handleDelete = async (shelf: GenericShelf) => {
|
||||||
@@ -95,7 +92,7 @@ export function ShelvesSection() {
|
|||||||
|
|
||||||
{shelves.length > 0 && (
|
{shelves.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowProviderSelect(true)}
|
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"
|
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
|
<svg
|
||||||
@@ -136,28 +133,13 @@ export function ShelvesSection() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState onAdd={() => setShowProviderSelect(true)} />
|
<EmptyState onAdd={() => setShowAddShelf(true)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<ProviderSelectModal
|
<AddShelfModal
|
||||||
isOpen={showProviderSelect}
|
isOpen={showAddShelf}
|
||||||
onClose={() => setShowProviderSelect(false)}
|
onClose={() => setShowAddShelf(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)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedAsin && (
|
{selectedAsin && (
|
||||||
@@ -172,61 +154,6 @@ export function ShelvesSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 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 }) {
|
||||||
|
|||||||
@@ -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 → select a shelf → 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
/**
|
|
||||||
* Component: Add Hardcover 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 { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
|
||||||
|
|
||||||
interface AddHardcoverShelfModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddHardcoverShelfModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
}: AddHardcoverShelfModalProps) {
|
|
||||||
const [apiToken, setApiToken] = useState('');
|
|
||||||
const [listId, setListId] = useState('');
|
|
||||||
const [validationError, setValidationError] = useState('');
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
|
||||||
const { addShelf, isLoading, error } = useAddHardcoverShelf();
|
|
||||||
|
|
||||||
const validateInput = (): boolean => {
|
|
||||||
if (!apiToken.trim()) {
|
|
||||||
setValidationError('Hardcover API Token is required');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!listId.trim()) {
|
|
||||||
setValidationError('Hardcover List ID or Status ID is required');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setValidationError('');
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!validateInput()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const shelf = await addShelf(apiToken.trim(), listId.trim());
|
|
||||||
setSuccess(true);
|
|
||||||
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
|
|
||||||
setApiToken('');
|
|
||||||
setListId('');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setSuccess(false);
|
|
||||||
onClose();
|
|
||||||
}, 2000);
|
|
||||||
} catch {
|
|
||||||
// Error is handled by the hook
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setApiToken('');
|
|
||||||
setListId('');
|
|
||||||
setValidationError('');
|
|
||||||
setSuccess(false);
|
|
||||||
setSuccessMessage('');
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={handleClose}
|
|
||||||
title="Add Hardcover List"
|
|
||||||
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-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">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-indigo-600 dark:text-indigo-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 className="min-w-0">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
|
||||||
Provides your Hardcover API token and the ID of 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 */}
|
|
||||||
{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 className="space-y-3">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="API Token"
|
|
||||||
value={apiToken}
|
|
||||||
onChange={(e) => {
|
|
||||||
setApiToken(e.target.value);
|
|
||||||
if (validationError) setValidationError('');
|
|
||||||
}}
|
|
||||||
placeholder="eyJhb..."
|
|
||||||
disabled={isLoading || success}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="List ID or Status ID"
|
|
||||||
value={listId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setListId(e.target.value);
|
|
||||||
if (validationError) setValidationError('');
|
|
||||||
}}
|
|
||||||
placeholder="1234 or uuid"
|
|
||||||
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 List
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* 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">
|
||||||
|
<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.
|
||||||
|
</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">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-indigo-600 dark:text-indigo-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 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 → select a shelf → 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,107 +38,210 @@ export async function fetchHardcoverList(
|
|||||||
apiToken: string,
|
apiToken: string,
|
||||||
listIdStr: string,
|
listIdStr: string,
|
||||||
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
|
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
|
||||||
// If we can parse as integer, it could be a List ID or Status ID. If UUID, we adjust query
|
// Check if it's a status list
|
||||||
const isUuid =
|
const isStatus = listIdStr.startsWith('status-');
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
||||||
listIdStr,
|
if (isStatus) {
|
||||||
|
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
||||||
|
const query = `
|
||||||
|
query GetStatusBooks($statusId: Int!) {
|
||||||
|
user_books(where: {status_id: {_eq: $statusId}}) {
|
||||||
|
book {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
contributions {
|
||||||
|
author {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cached_image
|
||||||
|
image {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
HARDCOVER_API_URL,
|
||||||
|
{ query, variables: { statusId } },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Example generic query to Hardcover. Adjust the table/format as needed for their schema.
|
if (response.data?.errors) {
|
||||||
// Hardcover lists use custom lists (list_books) or statuses (user_books).
|
throw new Error(
|
||||||
// Assuming list_books for this implementation.
|
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||||
const query = `
|
);
|
||||||
query GetListBooks($listId: Int!) {
|
}
|
||||||
list_books(where: {list_id: {_eq: $listId}}) {
|
|
||||||
list {
|
const userBooks = response.data?.data?.user_books || [];
|
||||||
name
|
let listName = 'Hardcover Status List';
|
||||||
}
|
|
||||||
book {
|
// Map status numbers to names
|
||||||
id
|
const statusNames: Record<number, string> = {
|
||||||
title
|
1: 'Want to Read',
|
||||||
author_books {
|
2: 'Currently Reading',
|
||||||
author {
|
3: 'Read',
|
||||||
name
|
4: 'Did Not Finish',
|
||||||
}
|
};
|
||||||
|
listName = statusNames[statusId] || `Status ${statusId}`;
|
||||||
|
|
||||||
|
const books: HardcoverApiBook[] = [];
|
||||||
|
for (const item of userBooks) {
|
||||||
|
const book = item.book;
|
||||||
|
if (!book || !book.id) continue;
|
||||||
|
|
||||||
|
const authorName =
|
||||||
|
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
||||||
|
const coverUrl = book.cached_image || book.image?.url || undefined;
|
||||||
|
|
||||||
|
books.push({
|
||||||
|
bookId: book.id.toString(),
|
||||||
|
title: book.title || 'Unknown Title',
|
||||||
|
author: authorName,
|
||||||
|
coverUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listName, books };
|
||||||
|
} else {
|
||||||
|
// Original list_books logic
|
||||||
|
let isUuid = false;
|
||||||
|
let isIntId = false;
|
||||||
|
let extractedSlug = listIdStr;
|
||||||
|
|
||||||
|
if (
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
listIdStr,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
isUuid = true;
|
||||||
|
} else if (/^\d+$/.test(listIdStr)) {
|
||||||
|
isIntId = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (listIdStr.includes('hardcover.app')) {
|
||||||
|
const url = new URL(
|
||||||
|
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
|
||||||
|
);
|
||||||
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
|
if (parts.length > 0) {
|
||||||
|
extractedSlug = parts[parts.length - 1];
|
||||||
}
|
}
|
||||||
cached_image
|
}
|
||||||
image {
|
} catch (e) {
|
||||||
url
|
// use extractedSlug as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query GetListBooks($listId: Int!) {
|
||||||
|
list_books(where: {list_id: {_eq: $listId}}) {
|
||||||
|
list { name }
|
||||||
|
book {
|
||||||
|
id title cached_image image { url }
|
||||||
|
contributions { author { name } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
`;
|
||||||
`;
|
|
||||||
|
|
||||||
// Provide fallback UUID query if Hardcover uses UUIDs instead.
|
const queryUuid = `
|
||||||
const queryUuid = `
|
query GetListBooksUuid($listId: uuid!) {
|
||||||
query GetListBooksUuid($listId: uuid!) {
|
list_books(where: {list_id: {_eq: $listId}}) {
|
||||||
list_books(where: {list_id: {_eq: $listId}}) {
|
list { name }
|
||||||
list {
|
book {
|
||||||
name
|
id title cached_image image { url }
|
||||||
}
|
contributions { author { name } }
|
||||||
book {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
author_books {
|
|
||||||
author {
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cached_image
|
|
||||||
image {
|
|
||||||
url
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const querySlug = `
|
||||||
|
query GetListBooksBySlug($slug: String!) {
|
||||||
|
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
||||||
|
name
|
||||||
|
list_books {
|
||||||
|
book {
|
||||||
|
id title cached_image image { url }
|
||||||
|
contributions { author { name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const isSlug = !isUuid && !isIntId;
|
||||||
|
const activeQuery = isSlug ? querySlug : isUuid ? queryUuid : query;
|
||||||
|
const variables = isSlug
|
||||||
|
? { slug: extractedSlug }
|
||||||
|
: { listId: isUuid ? listIdStr : parseInt(listIdStr, 10) };
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
HARDCOVER_API_URL,
|
||||||
|
{
|
||||||
|
query: activeQuery,
|
||||||
|
variables,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.errors) {
|
||||||
|
throw new Error(
|
||||||
|
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
const response = await axios.post(
|
let listName = 'Hardcover List';
|
||||||
HARDCOVER_API_URL,
|
let listBooks: any[] = [];
|
||||||
{
|
|
||||||
query: isUuid ? queryUuid : query,
|
|
||||||
variables: {
|
|
||||||
listId: isUuid ? listIdStr : parseInt(listIdStr, 10),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
timeout: 15000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data?.errors) {
|
if (isSlug) {
|
||||||
throw new Error(`Hardcover API Error: ${response.data.errors[0]?.message}`);
|
const listsData = response.data?.data?.lists || [];
|
||||||
|
if (listsData.length === 0) {
|
||||||
|
throw new Error(`Could not find a list with slug "${extractedSlug}"`);
|
||||||
|
}
|
||||||
|
listName = listsData[0].name || listName;
|
||||||
|
listBooks = listsData[0].list_books || [];
|
||||||
|
} else {
|
||||||
|
listBooks = response.data?.data?.list_books || [];
|
||||||
|
if (listBooks.length > 0 && listBooks[0].list?.name) {
|
||||||
|
listName = listBooks[0].list.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const books: HardcoverApiBook[] = [];
|
||||||
|
for (const item of listBooks) {
|
||||||
|
const book = item.book;
|
||||||
|
if (!book || !book.id) continue;
|
||||||
|
|
||||||
|
const authorName =
|
||||||
|
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
||||||
|
const coverUrl = book.cached_image || book.image?.url || undefined;
|
||||||
|
|
||||||
|
books.push({
|
||||||
|
bookId: book.id.toString(),
|
||||||
|
title: book.title || 'Unknown Title',
|
||||||
|
author: authorName,
|
||||||
|
coverUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listName, books };
|
||||||
}
|
}
|
||||||
|
|
||||||
const listBooks = response.data?.data?.list_books || [];
|
|
||||||
let listName = 'Hardcover List';
|
|
||||||
if (listBooks.length > 0 && listBooks[0].list?.name) {
|
|
||||||
listName = listBooks[0].list.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const books: HardcoverApiBook[] = [];
|
|
||||||
for (const item of listBooks) {
|
|
||||||
const book = item.book;
|
|
||||||
if (!book || !book.id) continue;
|
|
||||||
|
|
||||||
// Hardcover authors can be multiple, we pick the first one or join them
|
|
||||||
const authorName = book.author_books?.[0]?.author?.name || 'Unknown Author';
|
|
||||||
const coverUrl = book.cached_image || book.image?.url || undefined;
|
|
||||||
|
|
||||||
books.push({
|
|
||||||
bookId: book.id.toString(),
|
|
||||||
title: book.title || 'Unknown Title',
|
|
||||||
author: authorName,
|
|
||||||
coverUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { listName, books };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HardcoverSyncStats {
|
export interface HardcoverSyncStats {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('https://api.hardcover.app/v1/graphql', {
|
||||||
|
query: "{ __schema { types { name } } }"
|
||||||
|
});
|
||||||
|
console.log(res.data);
|
||||||
|
} catch(e) {
|
||||||
|
console.log(e.response ? e.response.data : e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run();
|
||||||
Reference in New Issue
Block a user