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', )} >