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:
Huang Xin
2026-05-29 00:46:17 +08:00
committed by GitHub
parent 3c134380b7
commit 18c2115cc1
11 changed files with 262 additions and 15 deletions
@@ -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}
+13 -5
View File
@@ -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,
+7
View File
@@ -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(