From 18c2115cc1aff8388204bba8aafe7b07fe4bfbc7 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 29 May 2026 00:46:17 +0800 Subject: [PATCH] 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 ``. Apply the callout suppression to descendant images/anchors and disable native drag on the cover. Co-Authored-By: Claude Opus 4.7 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.7 --- .../library/failed-imports-dialog.test.tsx | 69 +++++++++++++++ .../src/__tests__/utils/library-utils.test.ts | 49 +++++++++++ .../components/FailedImportsDialog.tsx | 86 +++++++++++++++++++ .../app/library/components/GroupingModal.tsx | 16 ++-- .../app/library/components/LibraryHeader.tsx | 2 +- .../library/components/SelectModeActions.tsx | 3 +- .../app/library/components/SetStatusAlert.tsx | 3 +- apps/readest-app/src/app/library/page.tsx | 18 ++-- .../src/app/library/utils/libraryUtils.ts | 22 +++++ apps/readest-app/src/components/BookCover.tsx | 2 + apps/readest-app/src/styles/globals.css | 7 ++ 11 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 apps/readest-app/src/__tests__/app/library/failed-imports-dialog.test.tsx create mode 100644 apps/readest-app/src/app/library/components/FailedImportsDialog.tsx diff --git a/apps/readest-app/src/__tests__/app/library/failed-imports-dialog.test.tsx b/apps/readest-app/src/__tests__/app/library/failed-imports-dialog.test.tsx new file mode 100644 index 00000000..1b2e34db --- /dev/null +++ b/apps/readest-app/src/__tests__/app/library/failed-imports-dialog.test.tsx @@ -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) => { + 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; + }) => ( +
+

{title}

+
+ ), +})); + +afterEach(() => { + cleanup(); +}); + +describe('FailedImportsDialog', () => { + it('renders the count in the title and each failed filename', () => { + render( + , + ); + + 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( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'OK' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/library-utils.test.ts b/apps/readest-app/src/__tests__/utils/library-utils.test.ts index a50185db..6d09799a 100644 --- a/apps/readest-app/src/__tests__/utils/library-utils.test.ts +++ b/apps/readest-app/src/__tests__/utils/library-utils.test.ts @@ -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']); + }); +}); diff --git a/apps/readest-app/src/app/library/components/FailedImportsDialog.tsx b/apps/readest-app/src/app/library/components/FailedImportsDialog.tsx new file mode 100644 index 00000000..ef81ab64 --- /dev/null +++ b/apps/readest-app/src/app/library/components/FailedImportsDialog.tsx @@ -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 = ({ 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 ( + +
+
+
+ +
    + {failedImports.map((item, index) => ( +
  • +
  • + ))} +
+ +
+ +
+
+
+ ); +}; + +export default FailedImportsDialog; diff --git a/apps/readest-app/src/app/library/components/GroupingModal.tsx b/apps/readest-app/src/app/library/components/GroupingModal.tsx index b5dc4e06..a25c135a 100644 --- a/apps/readest-app/src/app/library/components/GroupingModal.tsx +++ b/apps/readest-app/src/app/library/components/GroupingModal.tsx @@ -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 = ({ 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)) || diff --git a/apps/readest-app/src/app/library/components/LibraryHeader.tsx b/apps/readest-app/src/app/library/components/LibraryHeader.tsx index f96db345..f9af5d78 100644 --- a/apps/readest-app/src/app/library/components/LibraryHeader.tsx +++ b/apps/readest-app/src/app/library/components/LibraryHeader.tsx @@ -193,7 +193,7 @@ const LibraryHeader: React.FC = ({ 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')} > - + {isSelectAll ? _('Deselect') : _('Select All')} diff --git a/apps/readest-app/src/app/library/components/SelectModeActions.tsx b/apps/readest-app/src/app/library/components/SelectModeActions.tsx index 700728ac..62bf24b3 100644 --- a/apps/readest-app/src/app/library/components/SelectModeActions.tsx +++ b/apps/readest-app/src/app/library/components/SelectModeActions.tsx @@ -53,7 +53,7 @@ const SelectModeActions: React.FC = ({ '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', )} >