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 { 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
|
||||
const existing = await prisma.hardcoverShelf.findUnique({
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { VersionBadge } from '@/components/ui/VersionBadge';
|
||||
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
|
||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
|
||||
export function Header() {
|
||||
@@ -21,8 +21,9 @@ export function Header() {
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
const [showBookDate, setShowBookDate] = useState(false);
|
||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||
const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
|
||||
const [showAddShelfModal, setShowAddShelfModal] = useState(false);
|
||||
const { containerRef, dropdownRef, positionAbove, style } =
|
||||
useSmartDropdownPosition(showUserMenu);
|
||||
|
||||
// Check if user can change password (local users only)
|
||||
const canChangePassword = user?.authProvider === 'local';
|
||||
@@ -44,16 +45,14 @@ export function Header() {
|
||||
|
||||
const response = await fetch('/api/bookdate/config', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// Show BookDate to any user with verified and enabled configuration
|
||||
setShowBookDate(
|
||||
data.config &&
|
||||
data.config.isVerified &&
|
||||
data.config.isEnabled
|
||||
data.config && data.config.isVerified && data.config.isEnabled,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to check BookDate config:', error);
|
||||
@@ -95,11 +94,11 @@ export function Header() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
setShowAddGoodreadsModal(true);
|
||||
setShowAddShelfModal(true);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Add Goodreads Shelf
|
||||
Add Shelf
|
||||
</button>
|
||||
{canChangePassword && (
|
||||
<button
|
||||
@@ -206,8 +205,18 @@ export function Header() {
|
||||
className="md:hidden p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
@@ -218,12 +227,32 @@ export function Header() {
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{showMobileMenu ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
@@ -327,7 +356,9 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
{/* User menu dropdown (rendered via portal) */}
|
||||
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)}
|
||||
{typeof window !== 'undefined' &&
|
||||
userMenuDropdown &&
|
||||
createPortal(userMenuDropdown, document.body)}
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<ChangePasswordModal
|
||||
@@ -335,10 +366,10 @@ export function Header() {
|
||||
onClose={() => setShowChangePasswordModal(false)}
|
||||
/>
|
||||
|
||||
{/* Add Goodreads Shelf Modal */}
|
||||
<AddGoodreadsShelfModal
|
||||
isOpen={showAddGoodreadsModal}
|
||||
onClose={() => setShowAddGoodreadsModal(false)}
|
||||
{/* Add Shelf Modal */}
|
||||
<AddShelfModal
|
||||
isOpen={showAddShelfModal}
|
||||
onClose={() => setShowAddShelfModal(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,7 @@ import React, { useState } from 'react';
|
||||
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
||||
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
|
||||
import { AddHardcoverShelfModal } from '@/components/ui/AddHardcoverShelfModal';
|
||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
@@ -40,9 +39,7 @@ export function ShelvesSection() {
|
||||
const { squareCovers } = usePreferences();
|
||||
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [showProviderSelect, setShowProviderSelect] = useState(false);
|
||||
const [showAddGoodreads, setShowAddGoodreads] = useState(false);
|
||||
const [showAddHardcover, setShowAddHardcover] = useState(false);
|
||||
const [showAddShelf, setShowAddShelf] = useState(false);
|
||||
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (shelf: GenericShelf) => {
|
||||
@@ -95,7 +92,7 @@ export function ShelvesSection() {
|
||||
|
||||
{shelves.length > 0 && (
|
||||
<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"
|
||||
>
|
||||
<svg
|
||||
@@ -136,28 +133,13 @@ export function ShelvesSection() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState onAdd={() => setShowProviderSelect(true)} />
|
||||
<EmptyState onAdd={() => setShowAddShelf(true)} />
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<ProviderSelectModal
|
||||
isOpen={showProviderSelect}
|
||||
onClose={() => setShowProviderSelect(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)}
|
||||
<AddShelfModal
|
||||
isOpen={showAddShelf}
|
||||
onClose={() => setShowAddShelf(false)}
|
||||
/>
|
||||
|
||||
{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 ─── */
|
||||
|
||||
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,
|
||||
listIdStr: string,
|
||||
): 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
|
||||
const isUuid =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
listIdStr,
|
||||
// Check if it's a status list
|
||||
const isStatus = listIdStr.startsWith('status-');
|
||||
|
||||
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.
|
||||
// Hardcover lists use custom lists (list_books) or statuses (user_books).
|
||||
// Assuming list_books for this implementation.
|
||||
const query = `
|
||||
query GetListBooks($listId: Int!) {
|
||||
list_books(where: {list_id: {_eq: $listId}}) {
|
||||
list {
|
||||
name
|
||||
}
|
||||
book {
|
||||
id
|
||||
title
|
||||
author_books {
|
||||
author {
|
||||
name
|
||||
}
|
||||
if (response.data?.errors) {
|
||||
throw new Error(
|
||||
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const userBooks = response.data?.data?.user_books || [];
|
||||
let listName = 'Hardcover Status List';
|
||||
|
||||
// Map status numbers to names
|
||||
const statusNames: Record<number, string> = {
|
||||
1: 'Want to Read',
|
||||
2: 'Currently Reading',
|
||||
3: 'Read',
|
||||
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 {
|
||||
url
|
||||
}
|
||||
} catch (e) {
|
||||
// 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 = `
|
||||
query GetListBooksUuid($listId: uuid!) {
|
||||
list_books(where: {list_id: {_eq: $listId}}) {
|
||||
list {
|
||||
name
|
||||
}
|
||||
book {
|
||||
id
|
||||
title
|
||||
author_books {
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
cached_image
|
||||
image {
|
||||
url
|
||||
const queryUuid = `
|
||||
query GetListBooksUuid($listId: uuid!) {
|
||||
list_books(where: {list_id: {_eq: $listId}}) {
|
||||
list { name }
|
||||
book {
|
||||
id title cached_image image { url }
|
||||
contributions { author { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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(
|
||||
HARDCOVER_API_URL,
|
||||
{
|
||||
query: isUuid ? queryUuid : query,
|
||||
variables: {
|
||||
listId: isUuid ? listIdStr : parseInt(listIdStr, 10),
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 15000,
|
||||
},
|
||||
);
|
||||
let listName = 'Hardcover List';
|
||||
let listBooks: any[] = [];
|
||||
|
||||
if (response.data?.errors) {
|
||||
throw new Error(`Hardcover API Error: ${response.data.errors[0]?.message}`);
|
||||
if (isSlug) {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user