feat(library): import-failure modal + group sort + Android callout fix (#4345)
* fix(library): suppress Android image callout on book covers Long-pressing a cover on Android could trigger the WebView's native image callout at the same time as the bookshelf's own 500ms long-press handler for multi-select, causing apparent freezes. `-webkit-touch- callout: none` doesn't inherit, so the existing `.no-context-menu` rule on the item container never reached the cover `<img>`. Apply the callout suppression to descendant images/anchors and disable native drag on the cover. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(library): show modal for multi-file import failures When a batch import yields more than one failure, the previous toast crammed every filename onto a single line that often overflowed and truncated. Add a dialog that lists each failed filename with its error reason, dedupes the message into a header banner when every file failed for the same reason, and falls back to the existing toast for single-file failures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(library): sort manage-group modal by most recent activity The Group Books modal listed groups in store-insertion order, which made recently-active groups hard to find in libraries with many groups. Sort each level desc by the newest `updatedAt` across the group's books, propagating up the path so a recently-touched book in `Literature/Fiction` keeps `Literature` fresh too. Extract the index as `buildGroupNameUpdatedAt` in libraryUtils for reuse and unit testing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(library): tighten select-mode action bar and header polish - SelectModeActions: switch the narrow-viewport grid from 3 columns to 4 (with the delete action explicitly placed in column 2) so the icon set stops wrapping awkwardly on phones below ~500px. - LibraryHeader: keep the "Select All" / "Deselect" label on a single line so it doesn't wrap and shove the underlying button taller. - SetStatusAlert: drop the hover bg on the small-screen cancel button and rely on text-color contrast so it stops flashing a tinted disc on mobile taps. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import FailedImportsDialog from '@/app/library/components/FailedImportsDialog';
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (key: string, options?: Record<string, string | number>) => {
|
||||
if (!options) return key;
|
||||
return key.replace(/{{(\w+)}}/g, (_match, name) => String(options[name] ?? ''));
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Dialog', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<div role='dialog' aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
<button type='button' aria-label='close-dialog' onClick={onClose} />
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('FailedImportsDialog', () => {
|
||||
it('renders the count in the title and each failed filename', () => {
|
||||
render(
|
||||
<FailedImportsDialog
|
||||
failedImports={[
|
||||
{ filename: 'book-a.epub', errorMessage: 'Unsupported format' },
|
||||
{ filename: 'book-b.pdf', errorMessage: '' },
|
||||
{ filename: 'book-c.mobi', errorMessage: 'File is corrupted' },
|
||||
]}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Failed to import 3 books' })).toBeTruthy();
|
||||
expect(screen.getByText('book-a.epub')).toBeTruthy();
|
||||
expect(screen.getByText('book-b.pdf')).toBeTruthy();
|
||||
expect(screen.getByText('book-c.mobi')).toBeTruthy();
|
||||
expect(screen.getByText('Unsupported format')).toBeTruthy();
|
||||
expect(screen.getByText('File is corrupted')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('invokes onClose when the OK button is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<FailedImportsDialog
|
||||
failedImports={[{ filename: 'book.epub', errorMessage: 'boom' }]}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'OK' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
findGroupById,
|
||||
getGroupDisplayName,
|
||||
expandBookshelfSelection,
|
||||
buildGroupNameUpdatedAt,
|
||||
} from '../../app/library/utils/libraryUtils';
|
||||
import { Book, BooksGroup } from '../../types/book';
|
||||
import { LibraryGroupByType, LibrarySortByType } from '../../types/settings';
|
||||
@@ -1065,3 +1066,51 @@ describe('expandBookshelfSelection', () => {
|
||||
expect(expandBookshelfSelection(['ghost-hash'], [])).toEqual(['ghost-hash']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGroupNameUpdatedAt', () => {
|
||||
it('takes the max updatedAt across books in each direct group', () => {
|
||||
const books = [
|
||||
createMockBook({ groupName: 'Fiction', updatedAt: 100 }),
|
||||
createMockBook({ groupName: 'Fiction', updatedAt: 300 }),
|
||||
createMockBook({ groupName: 'Fiction', updatedAt: 200 }),
|
||||
createMockBook({ groupName: 'History', updatedAt: 50 }),
|
||||
];
|
||||
const map = buildGroupNameUpdatedAt(books);
|
||||
expect(map.get('Fiction')).toBe(300);
|
||||
expect(map.get('History')).toBe(50);
|
||||
});
|
||||
|
||||
it('propagates a descendant book up to all ancestor groups', () => {
|
||||
const books = [
|
||||
createMockBook({ groupName: 'Literature/Fiction/Sci-Fi', updatedAt: 500 }),
|
||||
createMockBook({ groupName: 'Literature', updatedAt: 100 }),
|
||||
];
|
||||
const map = buildGroupNameUpdatedAt(books);
|
||||
expect(map.get('Literature/Fiction/Sci-Fi')).toBe(500);
|
||||
expect(map.get('Literature/Fiction')).toBe(500);
|
||||
expect(map.get('Literature')).toBe(500);
|
||||
});
|
||||
|
||||
it('ignores books without groupName or updatedAt', () => {
|
||||
const books = [
|
||||
createMockBook({ groupName: undefined, updatedAt: 999 }),
|
||||
createMockBook({ groupName: 'A', updatedAt: 0 }),
|
||||
createMockBook({ groupName: 'A', updatedAt: 42 }),
|
||||
];
|
||||
const map = buildGroupNameUpdatedAt(books);
|
||||
expect(map.get('A')).toBe(42);
|
||||
expect(map.size).toBe(1);
|
||||
});
|
||||
|
||||
it('produces a desc-sort by group freshness when used as the sort key', () => {
|
||||
const books = [
|
||||
createMockBook({ groupName: 'Old', updatedAt: 1 }),
|
||||
createMockBook({ groupName: 'Newer', updatedAt: 10 }),
|
||||
createMockBook({ groupName: 'Newest', updatedAt: 100 }),
|
||||
];
|
||||
const map = buildGroupNameUpdatedAt(books);
|
||||
const groups = [{ name: 'Old' }, { name: 'Newest' }, { name: 'Newer' }];
|
||||
groups.sort((a, b) => (map.get(b.name) ?? 0) - (map.get(a.name) ?? 0));
|
||||
expect(groups.map((g) => g.name)).toEqual(['Newest', 'Newer', 'Old']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { MdErrorOutline, MdInsertDriveFile } from 'react-icons/md';
|
||||
|
||||
import Dialog from '@/components/Dialog';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
export interface FailedImport {
|
||||
filename: string;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
interface FailedImportsDialogProps {
|
||||
failedImports: FailedImport[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FailedImportsDialog: React.FC<FailedImportsDialogProps> = ({ failedImports, onClose }) => {
|
||||
const _ = useTranslation();
|
||||
|
||||
const uniqueErrors = Array.from(
|
||||
new Set(failedImports.map((f) => f.errorMessage).filter(Boolean)),
|
||||
);
|
||||
const sharedError = uniqueErrors.length === 1 ? uniqueErrors[0] : null;
|
||||
const subtitle = sharedError ?? _('Some files could not be added to your library.');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
title={_('Failed to import {{count}} books', { count: failedImports.length })}
|
||||
onClose={onClose}
|
||||
boxClassName='sm:min-w-[440px] sm:max-w-[460px] sm:!h-auto sm:max-h-[80%]'
|
||||
contentClassName='!my-0 !px-5 !pt-0 !pb-4 !flex-grow-0'
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-xl',
|
||||
'bg-error/8 text-base-content border-error/15 border px-3.5 py-2.5',
|
||||
)}
|
||||
>
|
||||
<MdErrorOutline className='text-error h-5 w-5 flex-shrink-0' aria-hidden='true' />
|
||||
<p className='text-[0.85em] leading-snug'>{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
className={clsx(
|
||||
'bg-base-200/40 border-base-300/60 flex flex-col rounded-xl border',
|
||||
'divide-base-300/50 divide-y',
|
||||
)}
|
||||
>
|
||||
{failedImports.map((item, index) => (
|
||||
<li key={`${item.filename}-${index}`} className='flex items-center gap-2.5 px-3 py-2'>
|
||||
<MdInsertDriveFile
|
||||
className='text-base-content/40 h-4 w-4 flex-shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<span className='break-all text-[0.9em] font-medium leading-snug'>
|
||||
{item.filename}
|
||||
</span>
|
||||
{!sharedError && item.errorMessage && (
|
||||
<span className='text-base-content/55 break-words text-[0.78em] leading-snug'>
|
||||
{item.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className='flex justify-end pt-1'>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-contrast btn-sm min-w-24 rounded-lg'
|
||||
onClick={onClose}
|
||||
>
|
||||
{_('OK')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FailedImportsDialog;
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MdCheck, MdChevronRight, MdEdit } from 'react-icons/md';
|
||||
import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi';
|
||||
import { IoMdArrowBack } from 'react-icons/io';
|
||||
@@ -12,7 +12,7 @@ import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { getBreadcrumbs } from '../utils/libraryUtils';
|
||||
import { buildGroupNameUpdatedAt, getBreadcrumbs } from '../utils/libraryUtils';
|
||||
|
||||
interface GroupingModalProps {
|
||||
libraryBooks: Book[];
|
||||
@@ -55,12 +55,16 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
|
||||
const allGroups = getGroups();
|
||||
const currentGroups = getGroupsByParent(currentPath);
|
||||
const groupNameUpdatedAt = useMemo(() => buildGroupNameUpdatedAt(libraryBooks), [libraryBooks]);
|
||||
const sortedCurrentGroups = [...currentGroups].sort(
|
||||
(a, b) => (groupNameUpdatedAt.get(b.name) ?? 0) - (groupNameUpdatedAt.get(a.name) ?? 0),
|
||||
);
|
||||
const currentGroupsList =
|
||||
newGroup &&
|
||||
!currentGroups.some((g) => g.id === newGroup.id) &&
|
||||
!currentGroups.some((g) => newGroup.name.startsWith(g.name))
|
||||
? [newGroup, ...currentGroups]
|
||||
: currentGroups;
|
||||
!sortedCurrentGroups.some((g) => g.id === newGroup.id) &&
|
||||
!sortedCurrentGroups.some((g) => newGroup.name.startsWith(g.name))
|
||||
? [newGroup, ...sortedCurrentGroups]
|
||||
: sortedCurrentGroups;
|
||||
|
||||
const isSelectedBooksHasGroup =
|
||||
selectedBooks.some((hash) => !isMd5(hash)) ||
|
||||
|
||||
@@ -193,7 +193,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
className='btn btn-ghost text-base-content/85 h-8 min-h-8 w-[72px] p-0 sm:w-[80px]'
|
||||
aria-label={isSelectAll ? _('Deselect') : _('Select All')}
|
||||
>
|
||||
<span className='font-sans text-base font-normal sm:text-sm'>
|
||||
<span className='font-sans text-base font-normal sm:text-sm whitespace-nowrap truncate'>
|
||||
{isSelectAll ? _('Deselect') : _('Select All')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -53,7 +53,7 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
'not-eink:bg-base-300 eink:bg-base-100 eink:border eink:border-base-content',
|
||||
'mx-auto w-fit max-w-[calc(100vw-1rem)] rounded-lg p-4',
|
||||
'flex items-center justify-center gap-x-6',
|
||||
'max-sm:grid max-sm:grid-cols-3 max-sm:gap-x-6 max-sm:gap-y-3',
|
||||
'max-[500px]:grid max-[500px]:grid-cols-4 max-[500px]:gap-x-6 max-[500px]:gap-y-3',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -100,6 +100,7 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
onClick={onDelete}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
'max-[500px]:col-start-2',
|
||||
!hasSelection && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -65,8 +65,7 @@ const SetStatusAlert: React.FC<SetStatusAlertProps> = ({
|
||||
<button
|
||||
className={clsx(
|
||||
'absolute right-0 flex items-center justify-center',
|
||||
'rounded-full p-1.5 transition-colors',
|
||||
'hover:bg-base-content/10',
|
||||
'rounded-full p-1.5 transition-colors text-base-content/70 hover:text-base-content',
|
||||
'sm:hidden',
|
||||
)}
|
||||
onClick={onCancel}
|
||||
|
||||
@@ -82,6 +82,7 @@ import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import LibraryEmptyState from './components/LibraryEmptyState';
|
||||
import GroupHeader from './components/GroupHeader';
|
||||
import FailedImportsDialog, { FailedImport } from './components/FailedImportsDialog';
|
||||
import ImportFromFolderDialog, {
|
||||
ImportFromFolderResult,
|
||||
} from './components/ImportFromFolderDialog';
|
||||
@@ -187,6 +188,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
const [isSelectNone, setIsSelectNone] = useState(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [failedImportsModal, setFailedImportsModal] = useState<FailedImport[] | null>(null);
|
||||
// "Import from folder" dialog state. Held as a small object rather
|
||||
// than a boolean because we need a default starting directory to seed
|
||||
// the path field, and we want the dialog to remain mounted long
|
||||
@@ -755,14 +757,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
pushLibrary();
|
||||
|
||||
if (failedImports.length > 0) {
|
||||
const filenames = failedImports.map((f) => f.filename);
|
||||
const errorMessage = failedImports.find((f) => f.errorMessage)?.errorMessage || '';
|
||||
|
||||
if (failedImports.length > 1) {
|
||||
setFailedImportsModal(failedImports);
|
||||
} else if (failedImports.length === 1) {
|
||||
const { filename, errorMessage } = failedImports[0]!;
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message:
|
||||
_('Failed to import book(s): {{filenames}}', {
|
||||
filenames: listFormater(false).format(filenames),
|
||||
filenames: listFormater(false).format([filename]),
|
||||
}) + (errorMessage ? `\n${errorMessage}` : ''),
|
||||
timeout: 5000,
|
||||
type: 'error',
|
||||
@@ -1468,6 +1470,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<BackupWindow onPullLibrary={pullLibrary} />
|
||||
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
|
||||
{showCatalogManager && <CatalogDialog onClose={handleDismissOPDSDialog} />}
|
||||
{failedImportsModal && (
|
||||
<FailedImportsDialog
|
||||
failedImports={failedImportsModal}
|
||||
onClose={() => setFailedImportsModal(null)}
|
||||
/>
|
||||
)}
|
||||
{importFromFolderState && (
|
||||
<ImportFromFolderDialog
|
||||
initialDirectory={importFromFolderState.initialDirectory}
|
||||
|
||||
@@ -159,6 +159,28 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a `groupName -> max(book.updatedAt)` map for all groups touched by
|
||||
* the given books. Each book bumps both its direct group and every ancestor
|
||||
* group along its path (e.g. a book in "Literature/Fiction" also bumps
|
||||
* "Literature"), so parent groups don't sink just because their direct
|
||||
* members are stale.
|
||||
*/
|
||||
export const buildGroupNameUpdatedAt = (books: Book[]): Map<string, number> => {
|
||||
const map = new Map<string, number>();
|
||||
for (const book of books) {
|
||||
if (!book.groupName || !book.updatedAt) continue;
|
||||
let path: string | undefined = book.groupName;
|
||||
while (path) {
|
||||
const prev = map.get(path) ?? 0;
|
||||
if (book.updatedAt > prev) map.set(path, book.updatedAt);
|
||||
const slash = path.lastIndexOf('/');
|
||||
path = slash === -1 ? undefined : path.slice(0, slash);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const getBreadcrumbs = (currentPath: string) => {
|
||||
if (!currentPath) return [];
|
||||
const segments = currentPath.split('/');
|
||||
|
||||
@@ -81,6 +81,7 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
|
||||
alt={book.title}
|
||||
fill={true}
|
||||
loading='lazy'
|
||||
draggable={false}
|
||||
className={clsx('cover-image crop-cover-img object-cover', imageClassName)}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
@@ -104,6 +105,7 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
|
||||
height={0}
|
||||
sizes='100vw'
|
||||
loading='lazy'
|
||||
draggable={false}
|
||||
className={clsx(
|
||||
'cover-image fit-cover-img h-auto max-h-full w-auto max-w-full shadow-md',
|
||||
imageClassName,
|
||||
|
||||
@@ -423,6 +423,13 @@ foliate-fxl {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.no-context-menu img,
|
||||
.no-context-menu a {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.book-spine {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
|
||||
Reference in New Issue
Block a user