5a8f0873fa
* fix(library): refresh book cover after editing metadata Editing a book's cover in Book Details and saving showed the old cover until a full reload, in two render paths: - Library grid: handleUpdateMetadata mutated the book object in place, so the memoized <BookCover> compared fields off the same (mutated) reference and skipped re-rendering. Build a new book object via the new getBookWithUpdatedMetadata helper instead of mutating. - Book Details view: BookDetailView renders cover/title/author from the modal's `book` prop, which the parent never re-passed after save. BookDetailModal now tracks the saved book locally (displayBook) and renders the view from it. Adds a unit test for the immutable helper, a BookDetailModal regression test (edit cover -> save -> view reflects it), and a sample-alice.txt fixture for TXT import testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(agent): add cover-refresh stale-render memory Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
289 lines
9.0 KiB
TypeScript
289 lines
9.0 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useEffect, useState } from 'react';
|
|
|
|
import { Book } from '@/types/book';
|
|
import { getBookWithUpdatedMetadata } from '@/utils/book';
|
|
import { BookMetadata } from '@/libs/document';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useThemeStore } from '@/store/themeStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useMetadataEdit } from './useMetadataEdit';
|
|
import { DeleteAction } from '@/types/system';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { isWebAppPlatform } from '@/services/environment';
|
|
import Alert from '@/components/Alert';
|
|
import Dialog from '@/components/Dialog';
|
|
import BookDetailView from './BookDetailView';
|
|
import BookDetailEdit from './BookDetailEdit';
|
|
import SourceSelector from './SourceSelector';
|
|
import Spinner from '../Spinner';
|
|
|
|
interface BookDetailModalProps {
|
|
book: Book;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
handleBookDownload?: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
|
|
handleBookUpload?: (book: Book) => void;
|
|
handleBookDelete?: (book: Book) => void;
|
|
handleBookDeleteCloudBackup?: (book: Book) => void;
|
|
handleBookDeleteLocalCopy?: (book: Book) => void;
|
|
handleBookMetadataUpdate?: (book: Book, updatedMetadata: BookMetadata) => void;
|
|
}
|
|
|
|
interface DeleteConfig {
|
|
title: string;
|
|
message: string;
|
|
handler?: (book: Book) => void;
|
|
}
|
|
|
|
const BookDetailModal: React.FC<BookDetailModalProps> = ({
|
|
book,
|
|
isOpen,
|
|
onClose,
|
|
handleBookDownload,
|
|
handleBookUpload,
|
|
handleBookDelete,
|
|
handleBookDeleteCloudBackup,
|
|
handleBookDeleteLocalCopy,
|
|
handleBookMetadataUpdate,
|
|
}) => {
|
|
const _ = useTranslation();
|
|
const { envConfig, appService } = useEnv();
|
|
const { safeAreaInsets } = useThemeStore();
|
|
const [activeDeleteAction, setActiveDeleteAction] = useState<DeleteAction | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [bookMeta, setBookMeta] = useState<BookMetadata | null>(null);
|
|
const [fileSize, setFileSize] = useState<number | null>(null);
|
|
// The parent owns the `book` prop and does not re-pass it after a metadata
|
|
// save, so the details view tracks the saved book locally to refresh its
|
|
// cover/title/author immediately (otherwise it shows the stale prop).
|
|
const [displayBook, setDisplayBook] = useState<Book>(book);
|
|
|
|
// Initialize metadata edit hook
|
|
const {
|
|
editedMeta,
|
|
fieldSources,
|
|
lockedFields,
|
|
fieldErrors,
|
|
searchLoading,
|
|
showSourceSelection,
|
|
availableSources,
|
|
handleFieldChange,
|
|
handleToggleFieldLock,
|
|
handleLockAll,
|
|
handleUnlockAll,
|
|
handleAutoRetrieve,
|
|
handleSourceSelection,
|
|
handleCloseSourceSelection,
|
|
resetToOriginal,
|
|
} = useMetadataEdit(bookMeta);
|
|
|
|
const deleteConfigs: Record<DeleteAction, DeleteConfig> = {
|
|
both: {
|
|
title: _('Confirm Deletion'),
|
|
message: _('Are you sure to delete the selected book?'),
|
|
handler: handleBookDelete,
|
|
},
|
|
cloud: {
|
|
title: _('Confirm Deletion'),
|
|
message: _('Are you sure to delete the cloud backup of the selected book?'),
|
|
handler: handleBookDeleteCloudBackup,
|
|
},
|
|
local: {
|
|
title: _('Confirm Deletion'),
|
|
message: _('Are you sure to delete the local copy of the selected book?'),
|
|
handler: handleBookDeleteLocalCopy,
|
|
},
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchBookDetails = async () => {
|
|
const appService = await envConfig.getAppService();
|
|
try {
|
|
let details = book.metadata || null;
|
|
if (!details && book.downloadedAt) {
|
|
details = await appService.fetchBookDetails(book);
|
|
}
|
|
setBookMeta(details);
|
|
const size = await appService.getBookFileSize(book);
|
|
setFileSize(size);
|
|
} finally {
|
|
}
|
|
};
|
|
fetchBookDetails();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [book]);
|
|
|
|
useEffect(() => {
|
|
setDisplayBook(book);
|
|
}, [book]);
|
|
|
|
const handleClose = () => {
|
|
setBookMeta(null);
|
|
setEditMode(false);
|
|
setActiveDeleteAction(null);
|
|
onClose();
|
|
};
|
|
|
|
const handleEditMetadata = () => {
|
|
setEditMode(true);
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
resetToOriginal();
|
|
setEditMode(false);
|
|
};
|
|
|
|
const handleSaveMetadata = () => {
|
|
if (editedMeta && handleBookMetadataUpdate) {
|
|
setBookMeta({ ...editedMeta });
|
|
// Capture the updated book before handleBookMetadataUpdate clears the
|
|
// temporary cover fields on editedMeta, so the view refreshes its cover.
|
|
setDisplayBook(getBookWithUpdatedMetadata(book, editedMeta));
|
|
handleBookMetadataUpdate(book, editedMeta);
|
|
setEditMode(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAction = (action: DeleteAction) => {
|
|
setActiveDeleteAction(action);
|
|
};
|
|
|
|
const confirmDeleteAction = async () => {
|
|
if (!activeDeleteAction) return;
|
|
|
|
const config = deleteConfigs[activeDeleteAction];
|
|
handleClose();
|
|
|
|
if (config.handler) {
|
|
config.handler(book);
|
|
}
|
|
};
|
|
|
|
const cancelDeleteAction = () => {
|
|
setActiveDeleteAction(null);
|
|
};
|
|
|
|
const handleDelete = () => handleDeleteAction('both');
|
|
const handleDeleteCloudBackup = () => handleDeleteAction('cloud');
|
|
const handleDeleteLocalCopy = () => handleDeleteAction('local');
|
|
|
|
const handleRedownload = async () => {
|
|
handleClose();
|
|
if (handleBookDownload) {
|
|
handleBookDownload(book, { redownload: true, queued: false });
|
|
}
|
|
};
|
|
|
|
const handleReupload = async () => {
|
|
handleClose();
|
|
if (handleBookUpload) {
|
|
handleBookUpload(book);
|
|
}
|
|
};
|
|
|
|
const handleBookExport = async () => {
|
|
setIsLoading(true);
|
|
setTimeout(async () => {
|
|
const success = await appService?.exportBook(book);
|
|
setIsLoading(false);
|
|
if (!isWebAppPlatform()) {
|
|
eventDispatcher.dispatch('toast', {
|
|
type: success ? 'info' : 'error',
|
|
message: success ? _('Book exported successfully.') : _('Failed to export the book.'),
|
|
});
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
const currentDeleteConfig = activeDeleteAction ? deleteConfigs[activeDeleteAction] : null;
|
|
|
|
return (
|
|
<>
|
|
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
|
<Dialog
|
|
title={editMode ? _('Edit Metadata') : _('Book Details')}
|
|
isOpen={isOpen}
|
|
onClose={handleClose}
|
|
boxClassName={clsx(
|
|
editMode ? 'sm:min-w-[600px] sm:max-w-[600px]' : 'sm:min-w-[480px] sm:max-w-[480px]',
|
|
'sm:h-auto sm:max-h-[90%]',
|
|
)}
|
|
contentClassName='!px-6 !py-4'
|
|
>
|
|
<div className='flex w-full select-text items-start justify-center'>
|
|
{editMode && bookMeta ? (
|
|
<BookDetailEdit
|
|
book={book}
|
|
metadata={editedMeta}
|
|
fieldSources={fieldSources}
|
|
lockedFields={lockedFields}
|
|
fieldErrors={fieldErrors}
|
|
searchLoading={searchLoading}
|
|
onFieldChange={handleFieldChange}
|
|
onToggleFieldLock={handleToggleFieldLock}
|
|
onAutoRetrieve={handleAutoRetrieve}
|
|
onLockAll={handleLockAll}
|
|
onUnlockAll={handleUnlockAll}
|
|
onCancel={handleCancelEdit}
|
|
onReset={resetToOriginal}
|
|
onSave={handleSaveMetadata}
|
|
/>
|
|
) : (
|
|
<BookDetailView
|
|
book={displayBook}
|
|
metadata={bookMeta}
|
|
fileSize={fileSize}
|
|
onEdit={handleBookMetadataUpdate ? handleEditMetadata : undefined}
|
|
onDelete={handleBookDelete ? handleDelete : undefined}
|
|
onDeleteCloudBackup={
|
|
handleBookDeleteCloudBackup ? handleDeleteCloudBackup : undefined
|
|
}
|
|
onDeleteLocalCopy={handleBookDeleteLocalCopy ? handleDeleteLocalCopy : undefined}
|
|
onDownload={handleBookDownload ? handleRedownload : undefined}
|
|
onUpload={handleBookUpload ? handleReupload : undefined}
|
|
onExport={handleBookExport}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Source Selection Modal */}
|
|
{showSourceSelection && (
|
|
<SourceSelector
|
|
sources={availableSources}
|
|
isOpen={showSourceSelection}
|
|
onSelect={handleSourceSelection}
|
|
onClose={handleCloseSourceSelection}
|
|
/>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
|
<Spinner loading />
|
|
</div>
|
|
)}
|
|
|
|
{activeDeleteAction && currentDeleteConfig && (
|
|
<div
|
|
className={clsx('fixed bottom-0 left-0 right-0 z-50 flex justify-center')}
|
|
style={{
|
|
paddingBottom: `${(safeAreaInsets?.bottom || 0) + 16}px`,
|
|
}}
|
|
>
|
|
<Alert
|
|
title={currentDeleteConfig.title}
|
|
message={currentDeleteConfig.message}
|
|
onCancel={cancelDeleteAction}
|
|
onConfirm={confirmDeleteAction}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default BookDetailModal;
|