diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index 7e07213..b6ec6a8 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -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({ diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index eff6201..fe05a2d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -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() { {canChangePassword && ( @@ -327,7 +356,9 @@ export function Header() { {/* 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 */} setShowChangePasswordModal(false)} /> - {/* Add Goodreads Shelf Modal */} - setShowAddGoodreadsModal(false)} + {/* Add Shelf Modal */} + setShowAddShelfModal(false)} /> ); diff --git a/src/components/profile/ShelvesSection.tsx b/src/components/profile/ShelvesSection.tsx index a84ec11..afe7879 100644 --- a/src/components/profile/ShelvesSection.tsx +++ b/src/components/profile/ShelvesSection.tsx @@ -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(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(null); const handleDelete = async (shelf: GenericShelf) => { @@ -95,7 +92,7 @@ export function ShelvesSection() { {shelves.length > 0 && ( - - - - ); -} - /* ─── Empty State ─── */ function EmptyState({ onAdd }: { onAdd: () => void }) { diff --git a/src/components/ui/AddGoodreadsShelfModal.tsx b/src/components/ui/AddGoodreadsShelfModal.tsx deleted file mode 100644 index dd0489b..0000000 --- a/src/components/ui/AddGoodreadsShelfModal.tsx +++ /dev/null @@ -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 ( - -
- {/* Visual header */} -
-
- - - -
-
-

- Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync. -

-
-
- - {/* Success alert */} - {success && ( -
-
- - - -
-

{successMessage}

-
- )} - - {/* Error alert */} - {error && ( -
-
- - - -
-

{error}

-
- )} - - {/* Form */} -
-
- { - setRssUrl(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="https://www.goodreads.com/review/list_rss/..." - error={validationError} - disabled={isLoading || success} - /> -

- Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page. -

-
- -
- - -
-
-
-
- ); -} diff --git a/src/components/ui/AddHardcoverShelfModal.tsx b/src/components/ui/AddHardcoverShelfModal.tsx deleted file mode 100644 index 6ac45f7..0000000 --- a/src/components/ui/AddHardcoverShelfModal.tsx +++ /dev/null @@ -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 ( - -
- {/* Visual header */} -
-
- - - -
-
-

- Provides your Hardcover API token and the ID of the list you want - to sync. -

-
-
- - {/* Success alert */} - {success && ( -
-
- - - -
-

- {successMessage} -

-
- )} - - {/* Error alert */} - {error && ( -
-
- - - -
-

- {error} -

-
- )} - - {/* Form */} -
-
- { - setApiToken(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="eyJhb..." - disabled={isLoading || success} - /> - { - setListId(e.target.value); - if (validationError) setValidationError(''); - }} - placeholder="1234 or uuid" - error={validationError} - disabled={isLoading || success} - /> -
- -
- - -
-
-
-
- ); -} diff --git a/src/components/ui/AddShelfModal.tsx b/src/components/ui/AddShelfModal.tsx new file mode 100644 index 0000000..de42246 --- /dev/null +++ b/src/components/ui/AddShelfModal.tsx @@ -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 ( + +
+ {/* Provider Selection Tabs */} +
+ + +
+ + {/* Visual header */} +
+ {provider === 'goodreads' ? ( + <> +
+ + + +
+
+

+ Paste your Goodreads shelf RSS URL. Books will be + automatically requested. +

+
+ + ) : ( + <> +
+ + + +
+
+

+ Provide your Hardcover API token and select the list you want + to sync. +

+
+ + )} +
+ + {/* Success alert */} + {success && ( +
+
+ + + +
+

+ {successMessage} +

+
+ )} + + {/* Error alert */} + {currentError && ( +
+
+ + + +
+

+ {currentError} +

+
+ )} + + {/* Form */} +
+ {provider === 'goodreads' ? ( +
+ { + setRssUrl(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="https://www.goodreads.com/review/list_rss/..." + error={validationError} + disabled={isLoading || success} + /> +

+ Find it on Goodreads: My Books → select a shelf → RSS + link at the bottom of the page. +

+
+ ) : ( +
+ { + setApiToken(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="eyJhb..." + disabled={isLoading || success} + /> + +
+ +
+ + +
+
+ + {listType === 'status' ? ( +
+ +
+ ) : ( + { + setCustomListId(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="https://hardcover.app/@username/lists/..." + error={validationError} + disabled={isLoading || success} + /> + )} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts index 2149c40..3512f90 100644 --- a/src/lib/services/hardcover-sync.service.ts +++ b/src/lib/services/hardcover-sync.service.ts @@ -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 = { + 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 { diff --git a/test_hardcover.js b/test_hardcover.js new file mode 100644 index 0000000..c6ba0d3 --- /dev/null +++ b/test_hardcover.js @@ -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();