mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 21:30:11 +00:00
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:
@@ -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 {
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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,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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user