Fix token handling, modal behavior, and pagination

Multiple fixes and improvements:

- src/app/api/user/hardcover-shelves/[id]/route.ts: Make token testing more robust by using the existing shelf.apiToken when no new token is provided, attempt decryption only when needed, and gracefully fall back on decryption errors.
- src/components/ui/AddShelfModal.tsx: Simplify token handling by passing the trimmed token directly to addHardcover (remove client-side 'Bearer ' stripping).
- src/components/ui/ManageShelfModal.tsx: Stabilize form reset effect by depending on shelf?.id to avoid unnecessary re-renders when the shelf object changes identity.
- src/components/ui/Modal.tsx: Simplify modal rendering by removing the mounted state and createPortal usage, cleaning up imports and rendering directly.
- src/lib/services/hardcover-api.service.ts: Add a logger, introduce a MAX_PAGES cap and page counters to prevent unbounded pagination loops, and log/break when the API returns errors during pagination.

These changes improve reliability (token handling and pagination safety), reduce unnecessary renders, and simplify modal lifecycle.
This commit is contained in:
kikootwo
2026-03-04 10:55:37 -05:00
parent 7f706e806f
commit c29cfa3a07
5 changed files with 31 additions and 30 deletions
@@ -106,13 +106,16 @@ export async function PATCH(
// Validate token/listId by fetching the list before saving // Validate token/listId by fetching the list before saving
if (cleanedToken || newListId) { if (cleanedToken || newListId) {
const encryptionService = getEncryptionService(); const encryptionService = getEncryptionService();
const tokenToTest = cleanedToken || (() => { let tokenToTest = cleanedToken || shelf.apiToken;
if (!cleanedToken) {
try { try {
return encryptionService.isEncryptedFormat(shelf.apiToken) if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
? encryptionService.decrypt(shelf.apiToken) tokenToTest = encryptionService.decrypt(shelf.apiToken);
: shelf.apiToken; }
} catch { return shelf.apiToken; } } catch {
})(); // Decryption failed, fall back to raw token
}
}
const listIdToTest = newListId || shelf.listId; const listIdToTest = newListId || shelf.listId;
try { try {
+1 -5
View File
@@ -77,11 +77,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
setRssUrl(''); setRssUrl('');
} else { } else {
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim(); const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
let cleanedToken = apiToken.trim(); const shelf = await addHardcover(apiToken.trim(), finalId);
if (cleanedToken.toLowerCase().startsWith('bearer ')) {
cleanedToken = cleanedToken.slice(7).trim();
}
const shelf = await addHardcover(cleanedToken, finalId);
setSuccessMessage(`Added list "${shelf.name}" successfully!`); setSuccessMessage(`Added list "${shelf.name}" successfully!`);
setApiToken(''); setApiToken('');
setCustomListId(''); setCustomListId('');
+2 -2
View File
@@ -26,14 +26,14 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf(); const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf();
const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf(); const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf();
// Reset form when shelf changes // Reset form when shelf changes (use shelf?.id for stable reference)
React.useEffect(() => { React.useEffect(() => {
if (shelf) { if (shelf) {
setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : ''); setRssUrl(shelf.type === 'goodreads' ? shelf.sourceId : '');
setListId(shelf.type === 'hardcover' ? shelf.sourceId : ''); setListId(shelf.type === 'hardcover' ? shelf.sourceId : '');
setApiToken(''); setApiToken('');
} }
}, [shelf]); }, [shelf?.id]);
if (!shelf) return null; if (!shelf) return null;
+5 -14
View File
@@ -5,8 +5,7 @@
'use client'; 'use client';
import React, { useEffect, useRef, useCallback, useState } from 'react'; import React, { useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
interface ModalProps { interface ModalProps {
@@ -26,12 +25,6 @@ export function Modal({
size = 'md', size = 'md',
showCloseButton = true, showCloseButton = true,
}: ModalProps) { }: ModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Use ref to avoid re-running effect when onClose changes // Use ref to avoid re-running effect when onClose changes
const onCloseRef = useRef(onClose); const onCloseRef = useRef(onClose);
onCloseRef.current = onClose; onCloseRef.current = onClose;
@@ -60,7 +53,7 @@ export function Modal({
}; };
}, [isOpen, handleClose]); }, [isOpen, handleClose]);
if (!isOpen || !mounted) return null; if (!isOpen) return null;
const sizeClasses = { const sizeClasses = {
sm: 'max-w-md', sm: 'max-w-md',
@@ -70,8 +63,8 @@ export function Modal({
full: 'max-w-[95vw]', full: 'max-w-[95vw]',
}; };
const content = ( return (
<div className="fixed inset-0 z-[100] overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
@@ -84,7 +77,7 @@ export function Modal({
className={cn( className={cn(
'relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl', 'relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl',
'transform transition-all', 'transform transition-all',
sizeClasses[size], sizeClasses[size]
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -123,6 +116,4 @@ export function Modal({
</div> </div>
</div> </div>
); );
return createPortal(content, document.body);
} }
+14 -3
View File
@@ -7,7 +7,9 @@
*/ */
import axios from 'axios'; import axios from 'axios';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('HardcoverAPI');
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql'; const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
export interface HardcoverApiBook { export interface HardcoverApiBook {
@@ -33,6 +35,7 @@ interface HardcoverListData {
} }
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const MAX_PAGES = 50;
/** Extract HardcoverApiBook[] from an array of book-containing items */ /** Extract HardcoverApiBook[] from an array of book-containing items */
function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] { function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] {
@@ -106,9 +109,10 @@ export async function fetchHardcoverList(
const allBooks: HardcoverApiBook[] = []; const allBooks: HardcoverApiBook[] = [];
let offset = 0; let offset = 0;
let page = 0;
// Paginate until fewer results than PAGE_SIZE are returned // Paginate until fewer results than PAGE_SIZE are returned
while (true) { while (++page <= MAX_PAGES) {
const response = await axios.post( const response = await axios.post(
HARDCOVER_API_URL, HARDCOVER_API_URL,
{ query, variables: { statusId, limit: PAGE_SIZE, offset } }, { query, variables: { statusId, limit: PAGE_SIZE, offset } },
@@ -274,8 +278,9 @@ export async function fetchHardcoverList(
// Paginate if first page was full // Paginate if first page was full
if (firstPageItems.length >= PAGE_SIZE) { if (firstPageItems.length >= PAGE_SIZE) {
let offset = PAGE_SIZE; let offset = PAGE_SIZE;
let page = 1; // first page already fetched
while (true) { while (++page <= MAX_PAGES) {
const pageResponse = await axios.post( const pageResponse = await axios.post(
HARDCOVER_API_URL, HARDCOVER_API_URL,
{ {
@@ -291,7 +296,13 @@ export async function fetchHardcoverList(
}, },
); );
if (pageResponse.data?.errors) break; if (pageResponse.data?.errors) {
logger.warn('Hardcover pagination interrupted by API error', {
errors: pageResponse.data.errors,
offset,
});
break;
}
let pageListsData: HardcoverListData[]; let pageListsData: HardcoverListData[];
if (isIntId) { if (isIntId) {