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
@@ -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']);
});
});