Add enhanced grouping and sorting functionality to the library view (#3146)

* feat(library): implement grouping of books by series or author

- Added functionality to group books in the library by series or author.
- Introduced new utility functions for creating book groups and parsing author strings.
- Updated the LibraryPageContent to track the current group for navigation.
- Created a new GroupHeader component to display the current group context.
- Enhanced the Bookshelf component to support displaying grouped books.
- Updated settings to include grouping options in the view menu.

New files:
- src/__tests__/utils/libraryUtils.test.ts - Unit tests for new utility functions.
- src/app/library/components/GroupHeader.tsx - Component for displaying group context.
- src/app/library/utils/libraryUtils.ts - Utility functions for grouping and parsing authors.

* Use group by and sort by constants, and split sort/group by menus into their own elements

* Format code with autoformatter

* Remove any casting from tests

* Translate missing strings

* refactor: cleaner layout with collapsible view menu options

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
Adam Charron
2026-02-03 08:51:50 -05:00
committed by GitHub
parent e3d52891fb
commit 52c49ddef1
41 changed files with 1563 additions and 121 deletions
@@ -1057,5 +1057,8 @@
"Speed Reading Mode": "وضع القراءة السريعة",
"Scroll left": "تمرير لليسار",
"Scroll right": "تمرير لليمين",
"Library Sync Progress": "تقدم تزامن المكتبة"
"Library Sync Progress": "تقدم تزامن المكتبة",
"Back to library": "العودة إلى المكتبة",
"Manual": "يدوي",
"Group by...": "تجميع حسب..."
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "দ্রুত পাঠ্য মোড",
"Scroll left": "বামে স্ক্রোল করুন",
"Scroll right": "ডানে স্ক্রোল করুন",
"Library Sync Progress": "লাইব্রেরি সিঙ্ক অগ্রগতি"
"Library Sync Progress": "লাইব্রেরি সিঙ্ক অগ্রগতি",
"Back to library": "লাইব্রেরিতে ফিরে যান",
"Manual": "ম্যানুয়াল",
"Group by...": "গ্রুপ অনুযায়ী..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "མྱུར་ཀློག་རྣམ་པ།",
"Scroll left": "གཡོན་ལ་བཤུད་དགོས།",
"Scroll right": "གཡས་ལ་བཤུད་དགོས།",
"Library Sync Progress": "དཔེ་མཛོད་མཉམ་འབྱུང་གི་རིམ་པ།"
"Library Sync Progress": "དཔེ་མཛོད་མཉམ་འབྱུང་གི་རིམ་པ།",
"Back to library": "དཔེ་མཛོད་ལ་ལོག་པ།",
"Manual": "ལག་བཟོས།",
"Group by...": "དབྱེ་བ་འབྱེད་སྟངས།..."
}
@@ -1009,5 +1009,10 @@
"Speed Reading Mode": "Schnelllesemodus",
"Scroll left": "Nach links scrollen",
"Scroll right": "Nach rechts scrollen",
"Library Sync Progress": "Bibliotheks-Synchronisierungsfortschritt"
"Library Sync Progress": "Bibliotheks-Synchronisierungsfortschritt",
"Back to library": "Zurück zur Bibliothek",
"Manual": "Manuell",
"Group by...": "Gruppieren nach...",
"Group By": "Gruppieren nach",
"Sort": "Sortieren"
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "Λειτουργία ταχείας ανάγνωσης",
"Scroll left": "Κύλιση αριστερά",
"Scroll right": "Κύλιση δεξιά",
"Library Sync Progress": "Πρόοδος συγχρονισμού βιβλιοθήκης"
"Library Sync Progress": "Πρόοδος συγχρονισμού βιβλιοθήκης",
"Back to library": "Επιστροφή στη βιβλιοθήκη",
"Manual": "Χειροκίνητα",
"Group by...": "Ομαδοποίηση κατά..."
}
@@ -1021,5 +1021,8 @@
"Speed Reading Mode": "Modo de lectura rápida",
"Scroll left": "Desplazar a la izquierda",
"Scroll right": "Desplazar a la derecha",
"Library Sync Progress": "Progreso de sincronización de la biblioteca"
"Library Sync Progress": "Progreso de sincronización de la biblioteca",
"Back to library": "Volver a la biblioteca",
"Manual": "Manual",
"Group by...": "Agrupar por..."
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "حالت مطالعه سریع",
"Scroll left": "اسکرول به چپ",
"Scroll right": "اسکرول به راست",
"Library Sync Progress": "پیشرفت همگام‌سازی کتابخانه"
"Library Sync Progress": "پیشرفت همگام‌سازی کتابخانه",
"Back to library": "بازگشت به کتابخانه",
"Manual": "دستی",
"Group by...": "گروه‌بندی بر اساس..."
}
@@ -1021,5 +1021,8 @@
"Speed Reading Mode": "Mode lecture rapide",
"Scroll left": "Défiler vers la gauche",
"Scroll right": "Défiler vers la droite",
"Library Sync Progress": "Progression de la synchronisation de la bibliothèque"
"Library Sync Progress": "Progression de la synchronisation de la bibliothèque",
"Back to library": "Retour à la bibliothèque",
"Manual": "Manuel",
"Group by...": "Grouper par..."
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "त्वरित पठन मोड",
"Scroll left": "बाएं स्क्रॉल करें",
"Scroll right": "दाएं स्क्रॉल करें",
"Library Sync Progress": "पुस्तकालय सिंक प्रगति"
"Library Sync Progress": "पुस्तकालय सिंक प्रगति",
"Back to library": "लाइब्रेरी पर वापस जाएं",
"Manual": "मैनुअल",
"Group by...": "इसके द्वारा समूहबद्ध करें..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "Mode Baca Cepat",
"Scroll left": "Gulir ke kiri",
"Scroll right": "Gulir ke kanan",
"Library Sync Progress": "Kemajuan Sinkronisasi Pustaka"
"Library Sync Progress": "Kemajuan Sinkronisasi Pustaka",
"Back to library": "Kembali ke perpustakaan",
"Manual": "Manual",
"Group by...": "Kelompokkan menurut..."
}
@@ -1021,5 +1021,8 @@
"Speed Reading Mode": "Modalità lettura veloce",
"Scroll left": "Scorri a sinistra",
"Scroll right": "Scorri a destra",
"Library Sync Progress": "Avanzamento sincronizzazione libreria"
"Library Sync Progress": "Avanzamento sincronizzazione libreria",
"Back to library": "Torna alla libreria",
"Manual": "Manuale",
"Group by...": "Raggruppa per..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "速読モード",
"Scroll left": "左にスクロール",
"Scroll right": "右にスクロール",
"Library Sync Progress": "ライブラリ同期の進捗"
"Library Sync Progress": "ライブラリ同期の進捗",
"Back to library": "ライブラリに戻る",
"Manual": "手動",
"Group by...": "グループ化..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "속독 모드",
"Scroll left": "왼쪽으로 스크롤",
"Scroll right": "오른쪽으로 스크롤",
"Library Sync Progress": "라이브러리 동기화 진행 상황"
"Library Sync Progress": "라이브러리 동기화 진행 상황",
"Back to library": "라이브러리로 돌아가기",
"Manual": "수동",
"Group by...": "그룹화 기준..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "Mod Bacaan Pantas",
"Scroll left": "Tatal ke kiri",
"Scroll right": "Tatal ke kanan",
"Library Sync Progress": "Kemajuan Segerak Perpustakaan"
"Library Sync Progress": "Kemajuan Segerak Perpustakaan",
"Back to library": "Kembali ke perpustakaan",
"Manual": "Manual",
"Group by...": "Kumpulan mengikut..."
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "Snelle leesmodus",
"Scroll left": "Naar links scrollen",
"Scroll right": "Naar rechts scrollen",
"Library Sync Progress": "Voortgang bibliotheeksynchronisatie"
"Library Sync Progress": "Voortgang bibliotheeksynchronisatie",
"Back to library": "Terug naar bibliotheek",
"Manual": "Handmatig",
"Group by...": "Groeperen op..."
}
@@ -1033,5 +1033,8 @@
"Speed Reading Mode": "Tryb szybkiego czytania",
"Scroll left": "Przewiń w lewo",
"Scroll right": "Przewiń w prawo",
"Library Sync Progress": "Postęp synchronizacji biblioteki"
"Library Sync Progress": "Postęp synchronizacji biblioteki",
"Back to library": "Powrót do biblioteki",
"Manual": "Ręcznie",
"Group by...": "Grupuj według..."
}
@@ -1021,5 +1021,8 @@
"Speed Reading Mode": "Modo de leitura rápida",
"Scroll left": "Rolar para a esquerda",
"Scroll right": "Rolar para a direita",
"Library Sync Progress": "Progresso de sincronização da biblioteca"
"Library Sync Progress": "Progresso de sincronização da biblioteca",
"Back to library": "Voltar à biblioteca",
"Manual": "Manual",
"Group by...": "Agrupar por..."
}
@@ -1033,5 +1033,8 @@
"Speed Reading Mode": "Режим быстрого чтения",
"Scroll left": "Прокрутить влево",
"Scroll right": "Прокрутить вправо",
"Library Sync Progress": "Прогресс синхронизации библиотеки"
"Library Sync Progress": "Прогресс синхронизации библиотеки",
"Back to library": "Назад в библиотеку",
"Manual": "Вручную",
"Group by...": "Группировать по..."
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "වේගයෙන් කියවීමේ ක්‍රමය",
"Scroll left": "වමට අනුචලනය කරන්න",
"Scroll right": "දකුණට අනුචලනය කරන්න",
"Library Sync Progress": "පුස්තකාල සමමුහුර්ත කිරීමේ ප්‍රගතිය"
"Library Sync Progress": "පුස්තකාල සමමුහුර්ත කිරීමේ ප්‍රගතිය",
"Back to library": "පුස්තකාලයට ආපසු",
"Manual": "අත්පොත",
"Group by...": "මගින් සමූහනය කරන්න..."
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "Snabbläsningsläge",
"Scroll left": "Scrolla åt vänster",
"Scroll right": "Scrolla åt höger",
"Library Sync Progress": "Synkroniseringsframsteg för bibliotek"
"Library Sync Progress": "Synkroniseringsframsteg för bibliotek",
"Back to library": "Tillbaka till biblioteket",
"Manual": "Manuellt",
"Group by...": "Gruppera efter..."
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "வேக வாசிப்பு முறை",
"Scroll left": "இடதுபுறம் உருட்டவும்",
"Scroll right": "வலதுபுறம் உருட்டவும்",
"Library Sync Progress": "நூலக ஒத்திசைவு நிலை"
"Library Sync Progress": "நூலக ஒத்திசைவு நிலை",
"Back to library": "நூலகத்திற்குத் திரும்பு",
"Manual": "கையேடு",
"Group by...": "இதன் படி குழுவாக்கு..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "โหมดการอ่านเร็ว",
"Scroll left": "เลื่อนไปทางซ้าย",
"Scroll right": "เลื่อนไปทางขวา",
"Library Sync Progress": "ความคืบหน้าการซิงค์ห้องสมุด"
"Library Sync Progress": "ความคืบหน้าการซิงค์ห้องสมุด",
"Back to library": "กลับไปที่ห้องสมุด",
"Manual": "ทำเอง",
"Group by...": "จัดกลุ่มตาม..."
}
@@ -1009,5 +1009,8 @@
"Speed Reading Mode": "Hızlı Okuma Modu",
"Scroll left": "Sola kaydır",
"Scroll right": "Sağa kaydır",
"Library Sync Progress": "Kütüphane Senkronizasyon İlerlemesi"
"Library Sync Progress": "Kütüphane Senkronizasyon İlerlemesi",
"Back to library": "Kütüphaneye dön",
"Manual": "Manuel",
"Group by...": "Şuna göre grupla..."
}
@@ -1033,5 +1033,8 @@
"Speed Reading Mode": "Режим швидкого читання",
"Scroll left": "Прокрутити вліво",
"Scroll right": "Прокрутити вправо",
"Library Sync Progress": "Прогрес синхронізації бібліотеки"
"Library Sync Progress": "Прогрес синхронізації бібліотеки",
"Back to library": "Назад до бібліотеки",
"Manual": "Вручну",
"Group by...": "Групувати за..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "Chế độ đọc nhanh",
"Scroll left": "Cuộn sang trái",
"Scroll right": "Cuộn sang phải",
"Library Sync Progress": "Tiến trình đồng bộ hóa thư viện"
"Library Sync Progress": "Tiến trình đồng bộ hóa thư viện",
"Back to library": "Quay lại thư viện",
"Manual": "Thủ công",
"Group by...": "Nhóm theo..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "快读模式",
"Scroll left": "向左滚动",
"Scroll right": "向右滚动",
"Library Sync Progress": "库同步进度"
"Library Sync Progress": "库同步进度",
"Back to library": "返回书库",
"Manual": "手动",
"Group by...": "分组方式..."
}
@@ -997,5 +997,8 @@
"Speed Reading Mode": "快讀模式",
"Scroll left": "向左捲動",
"Scroll right": "向右捲動",
"Library Sync Progress": "資料庫同步進度"
"Library Sync Progress": "資料庫同步進度",
"Back to library": "返回書庫",
"Manual": "手動",
"Group by...": "分組方式..."
}
@@ -0,0 +1,843 @@
import { describe, it, expect } from 'vitest';
import {
parseAuthors,
createBookGroups,
createWithinGroupSorter,
createGroupSorter,
getGroupSortValue,
createBookSorter,
ensureLibrarySortByType,
ensureLibraryGroupByType,
findGroupById,
getGroupDisplayName,
} from '../../app/library/utils/libraryUtils';
import { Book, BooksGroup } from '../../types/book';
import { LibraryGroupByType, LibrarySortByType } from '../../types/settings';
import { BookMetadata } from '@/libs/document';
// Helper to create mock books with minimal required fields
const createMockBook = (
overrides: Partial<Omit<Book, 'metadata'> & { metadata?: Partial<BookMetadata> }> = {},
): Book => ({
hash: `hash-${Math.random().toString(36).substr(2, 9)}`,
format: 'EPUB',
title: 'Test Book',
author: 'Test Author',
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
metadata: { ...overrides.metadata } as BookMetadata,
});
describe('parseAuthors', () => {
it('should return single author as array', () => {
expect(parseAuthors('John Smith')).toEqual(['John Smith']);
});
it('should split authors by comma', () => {
expect(parseAuthors('John Smith, Jane Doe')).toEqual(['John Smith', 'Jane Doe']);
});
it('should split authors by ampersand', () => {
expect(parseAuthors('John Smith & Jane Doe')).toEqual(['John Smith', 'Jane Doe']);
});
it('should split authors by "and"', () => {
expect(parseAuthors('John Smith and Jane Doe')).toEqual(['John Smith', 'Jane Doe']);
});
it('should handle mixed separators', () => {
expect(parseAuthors('John Smith, Jane Doe & Bob Wilson')).toEqual([
'John Smith',
'Jane Doe',
'Bob Wilson',
]);
});
it('should handle "and" with commas', () => {
expect(parseAuthors('John Smith, Jane Doe, and Bob Wilson')).toEqual([
'John Smith',
'Jane Doe',
'Bob Wilson',
]);
});
it('should trim whitespace from author names', () => {
expect(parseAuthors(' John Smith , Jane Doe ')).toEqual(['John Smith', 'Jane Doe']);
});
it('should return empty array for empty string', () => {
expect(parseAuthors('')).toEqual([]);
});
it('should return empty array for whitespace only', () => {
expect(parseAuthors(' ')).toEqual([]);
});
it('should handle single author with extra spaces', () => {
expect(parseAuthors(' John Smith ')).toEqual(['John Smith']);
});
});
describe('createBookGroups', () => {
describe('groupBy: none', () => {
it('should return all books as flat list', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1' }),
createMockBook({ hash: '2', title: 'Book 2' }),
];
const result = createBookGroups(books, LibraryGroupByType.None);
expect(result).toHaveLength(2);
expect(result.every((item) => 'format' in item)).toBe(true);
});
it('should filter out deleted books', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1' }),
createMockBook({ hash: '2', title: 'Book 2', deletedAt: Date.now() }),
];
const result = createBookGroups(books, LibraryGroupByType.None);
expect(result).toHaveLength(1);
});
});
describe('groupBy: series', () => {
it('should group books by series name', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1', metadata: { series: 'Series A' } }),
createMockBook({ hash: '2', title: 'Book 2', metadata: { series: 'Series A' } }),
createMockBook({ hash: '3', title: 'Book 3', metadata: { series: 'Series B' } }),
];
const result = createBookGroups(books, LibraryGroupByType.Series);
const groups = result.filter((item): item is BooksGroup => 'books' in item);
expect(groups).toHaveLength(2);
const seriesA = groups.find((g) => g.name === 'Series A');
expect(seriesA?.books).toHaveLength(2);
const seriesB = groups.find((g) => g.name === 'Series B');
expect(seriesB?.books).toHaveLength(1);
});
it('should leave books without series as ungrouped items', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1', metadata: { series: 'Series A' } }),
createMockBook({ hash: '2', title: 'Book 2' }), // No series
createMockBook({ hash: '3', title: 'Book 3', metadata: {} }), // Empty metadata
];
const result = createBookGroups(books, LibraryGroupByType.Series);
const groups = result.filter((item): item is BooksGroup => 'books' in item);
const ungrouped = result.filter((item): item is Book => 'format' in item);
expect(groups).toHaveLength(1);
expect(ungrouped).toHaveLength(2);
});
it('should handle empty series string as ungrouped', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1', metadata: { series: '' } }),
createMockBook({ hash: '2', title: 'Book 2', metadata: { series: ' ' } }),
];
const result = createBookGroups(books, LibraryGroupByType.Series);
const groups = result.filter((item): item is BooksGroup => 'books' in item);
const ungrouped = result.filter((item): item is Book => 'format' in item);
expect(groups).toHaveLength(0);
expect(ungrouped).toHaveLength(2);
});
it('should set group updatedAt to most recent book', () => {
const books = [
createMockBook({
hash: '1',
title: 'Book 1',
metadata: { series: 'Series A' },
updatedAt: 1000,
}),
createMockBook({
hash: '2',
title: 'Book 2',
metadata: { series: 'Series A' },
updatedAt: 2000,
}),
];
const result = createBookGroups(books, LibraryGroupByType.Series);
const group = result.find((item): item is BooksGroup => 'books' in item);
expect(group?.updatedAt).toBe(2000);
});
});
describe('groupBy: author', () => {
it('should group books by author', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1', author: 'Author A' }),
createMockBook({ hash: '2', title: 'Book 2', author: 'Author A' }),
createMockBook({ hash: '3', title: 'Book 3', author: 'Author B' }),
];
const result = createBookGroups(books, LibraryGroupByType.Author);
const groups = result.filter((item): item is BooksGroup => 'books' in item);
expect(groups).toHaveLength(2);
const authorA = groups.find((g) => g.name === 'Author A');
expect(authorA?.books).toHaveLength(2);
});
it('should place book in multiple groups for multiple authors', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1', author: 'John Smith, Jane Doe' }),
];
const result = createBookGroups(books, LibraryGroupByType.Author);
const groups = result.filter((item): item is BooksGroup => 'books' in item);
expect(groups).toHaveLength(2);
const john = groups.find((g) => g.name === 'John Smith');
const jane = groups.find((g) => g.name === 'Jane Doe');
expect(john?.books).toHaveLength(1);
expect(jane?.books).toHaveLength(1);
expect(john?.books[0]!.hash).toBe(jane?.books[0]!.hash);
});
it('should leave books without author as ungrouped', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1', author: 'Author A' }),
createMockBook({ hash: '2', title: 'Book 2', author: '' }),
createMockBook({ hash: '3', title: 'Book 3', author: ' ' }),
];
const result = createBookGroups(books, LibraryGroupByType.Author);
const groups = result.filter((item): item is BooksGroup => 'books' in item);
const ungrouped = result.filter((item): item is Book => 'format' in item);
expect(groups).toHaveLength(1);
expect(ungrouped).toHaveLength(2);
});
});
describe('groupBy: manual', () => {
it('should return books as-is (manual mode handled elsewhere)', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1' }),
createMockBook({ hash: '2', title: 'Book 2' }),
];
const result = createBookGroups(books, LibraryGroupByType.Manual);
// Manual mode just returns filtered books - actual grouping is in generateBookshelfItems
expect(result).toHaveLength(2);
});
});
});
describe('createWithinGroupSorter', () => {
describe('series grouping', () => {
it('should sort by seriesIndex ascending', () => {
const books = [
createMockBook({
hash: '1',
title: 'Book 3',
metadata: { seriesIndex: 3 },
}),
createMockBook({
hash: '2',
title: 'Book 1',
metadata: { seriesIndex: 1 },
}),
createMockBook({
hash: '3',
title: 'Book 2',
metadata: { seriesIndex: 2 },
}),
];
const sorter = createWithinGroupSorter(
LibraryGroupByType.Series,
LibrarySortByType.Title,
'en',
);
const sorted = [...books].sort(sorter);
expect(sorted[0]!.metadata?.seriesIndex).toBe(1);
expect(sorted[1]!.metadata?.seriesIndex).toBe(2);
expect(sorted[2]!.metadata?.seriesIndex).toBe(3);
});
it('should place books without seriesIndex after those with index', () => {
const books = [
createMockBook({ hash: '1', title: 'Book A', metadata: {} }),
createMockBook({ hash: '2', title: 'Book B', metadata: { seriesIndex: 1 } }),
];
const sorter = createWithinGroupSorter(
LibraryGroupByType.Series,
LibrarySortByType.Title,
'en',
);
const sorted = [...books].sort(sorter);
expect(sorted[0]!.hash).toBe('2'); // Has index
expect(sorted[1]!.hash).toBe('1'); // No index
});
it('should sort books without seriesIndex by global sort criteria', () => {
const books = [
createMockBook({ hash: '1', title: 'Zebra', metadata: {} }),
createMockBook({ hash: '2', title: 'Apple', metadata: {} }),
];
const sorter = createWithinGroupSorter(
LibraryGroupByType.Series,
LibrarySortByType.Title,
'en',
);
const sorted = [...books].sort(sorter);
expect(sorted[0]!.title).toBe('Apple');
expect(sorted[1]!.title).toBe('Zebra');
});
it('should handle decimal seriesIndex values', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 2', metadata: { seriesIndex: 2 } }),
createMockBook({ hash: '2', title: 'Book 1.5', metadata: { seriesIndex: 1.5 } }),
createMockBook({ hash: '3', title: 'Book 1', metadata: { seriesIndex: 1 } }),
];
const sorter = createWithinGroupSorter(
LibraryGroupByType.Series,
LibrarySortByType.Title,
'en',
);
const sorted = [...books].sort(sorter);
expect(sorted[0]!.metadata?.seriesIndex).toBe(1);
expect(sorted[1]!.metadata?.seriesIndex).toBe(1.5);
expect(sorted[2]!.metadata?.seriesIndex).toBe(2);
});
});
describe('author grouping', () => {
it('should sort by global sort criteria (title)', () => {
const books = [
createMockBook({ hash: '1', title: 'Zebra' }),
createMockBook({ hash: '2', title: 'Apple' }),
];
const sorter = createWithinGroupSorter(
LibraryGroupByType.Author,
LibrarySortByType.Title,
'en',
);
const sorted = [...books].sort(sorter);
expect(sorted[0]!.title).toBe('Apple');
expect(sorted[1]!.title).toBe('Zebra');
});
it('should sort by global sort criteria (updated)', () => {
const books = [
createMockBook({ hash: '1', title: 'Book 1', updatedAt: 2000 }),
createMockBook({ hash: '2', title: 'Book 2', updatedAt: 1000 }),
];
const sorter = createWithinGroupSorter(
LibraryGroupByType.Author,
LibrarySortByType.Updated,
'en',
);
const sorted = [...books].sort(sorter);
expect(sorted[0]!.updatedAt).toBe(1000);
expect(sorted[1]!.updatedAt).toBe(2000);
});
});
});
describe('getGroupSortValue', () => {
const createMockGroup = (overrides: Partial<BooksGroup> = {}): BooksGroup => ({
id: 'test-group',
name: 'Test Group',
displayName: 'Test Group',
books: [],
updatedAt: Date.now(),
...overrides,
});
it('should return group name for title sort', () => {
const group = createMockGroup({ name: 'My Series' });
expect(getGroupSortValue(group, LibrarySortByType.Title)).toBe('My Series');
});
it('should return group name for author sort', () => {
const group = createMockGroup({ name: 'John Smith' });
expect(getGroupSortValue(group, LibrarySortByType.Author)).toBe('John Smith');
});
it('should return group name for format sort', () => {
const group = createMockGroup({ name: 'Test Group' });
expect(getGroupSortValue(group, LibrarySortByType.Format)).toBe('Test Group');
});
it('should return max updatedAt for date read sort', () => {
const group = createMockGroup({
books: [
createMockBook({ updatedAt: 1000 }),
createMockBook({ updatedAt: 3000 }),
createMockBook({ updatedAt: 2000 }),
],
});
expect(getGroupSortValue(group, LibrarySortByType.Updated)).toBe(3000);
});
it('should return max createdAt for date added sort', () => {
const group = createMockGroup({
books: [
createMockBook({ createdAt: 1000 }),
createMockBook({ createdAt: 3000 }),
createMockBook({ createdAt: 2000 }),
],
});
expect(getGroupSortValue(group, LibrarySortByType.Created)).toBe(3000);
});
it('should return max published date for published sort', () => {
const group = createMockGroup({
books: [
createMockBook({ metadata: { published: '2020-01-01' } }),
createMockBook({ metadata: { published: '2023-06-15' } }),
createMockBook({ metadata: { published: '2021-12-31' } }),
],
});
const result = getGroupSortValue(group, LibrarySortByType.Published);
expect(result).toBe(new Date('2023-06-15').getTime());
});
it('should handle missing published dates', () => {
const group = createMockGroup({
books: [createMockBook({ metadata: {} }), createMockBook({})],
});
expect(getGroupSortValue(group, LibrarySortByType.Published)).toBe(0);
});
it('should handle empty groups gracefully', () => {
const group = createMockGroup({ books: [] });
// Text-based sorts return group name
expect(getGroupSortValue(group, LibrarySortByType.Title)).toBe('Test Group');
// Numeric sorts return 0 for empty groups
expect(getGroupSortValue(group, LibrarySortByType.Updated)).toBe(0);
});
});
describe('createGroupSorter', () => {
const createMockGroup = (overrides: Partial<BooksGroup> = {}): BooksGroup => ({
id: 'test-group',
name: 'Test Group',
displayName: 'Test Group',
books: [],
updatedAt: Date.now(),
...overrides,
});
it('should sort groups alphabetically by name for title sort', () => {
const groups = [
createMockGroup({ name: 'Zebra Series' }),
createMockGroup({ name: 'Apple Series' }),
createMockGroup({ name: 'Mango Series' }),
];
const sorter = createGroupSorter(LibrarySortByType.Title, 'en');
const sorted = [...groups].sort(sorter);
expect(sorted[0]!.name).toBe('Apple Series');
expect(sorted[1]!.name).toBe('Mango Series');
expect(sorted[2]!.name).toBe('Zebra Series');
});
it('should sort groups by most recent updatedAt for date read sort', () => {
const groups = [
createMockGroup({
name: 'Group A',
books: [createMockBook({ updatedAt: 1000 })],
}),
createMockGroup({
name: 'Group B',
books: [createMockBook({ updatedAt: 3000 })],
}),
createMockGroup({
name: 'Group C',
books: [createMockBook({ updatedAt: 2000 })],
}),
];
const sorter = createGroupSorter(LibrarySortByType.Updated, 'en');
const sorted = [...groups].sort(sorter);
expect(sorted[0]!.name).toBe('Group A');
expect(sorted[1]!.name).toBe('Group C');
expect(sorted[2]!.name).toBe('Group B');
});
it('should sort groups by most recent createdAt for date added sort', () => {
const groups = [
createMockGroup({
name: 'Group A',
books: [createMockBook({ createdAt: 3000 })],
}),
createMockGroup({
name: 'Group B',
books: [createMockBook({ createdAt: 1000 })],
}),
];
const sorter = createGroupSorter(LibrarySortByType.Created, 'en');
const sorted = [...groups].sort(sorter);
expect(sorted[0]!.name).toBe('Group B');
expect(sorted[1]!.name).toBe('Group A');
});
it('should handle groups with single book', () => {
const groups = [
createMockGroup({
name: 'Group A',
books: [createMockBook({ updatedAt: 1000 })],
}),
createMockGroup({
name: 'Group B',
books: [createMockBook({ updatedAt: 2000 })],
}),
];
const sorter = createGroupSorter(LibrarySortByType.Updated, 'en');
const sorted = [...groups].sort(sorter);
expect(sorted[0]!.name).toBe('Group A');
expect(sorted[1]!.name).toBe('Group B');
});
});
describe('createBookSorter', () => {
it('should sort by title alphabetically', () => {
const books = [
createMockBook({ title: 'Zebra' }),
createMockBook({ title: 'Apple' }),
createMockBook({ title: 'Mango' }),
];
const sorter = createBookSorter(LibrarySortByType.Title, 'en');
const sorted = [...books].sort(sorter);
expect(sorted[0]!.title).toBe('Apple');
expect(sorted[1]!.title).toBe('Mango');
expect(sorted[2]!.title).toBe('Zebra');
});
it('should sort by updatedAt for date read', () => {
const books = [
createMockBook({ title: 'Book A', updatedAt: 2000 }),
createMockBook({ title: 'Book B', updatedAt: 1000 }),
createMockBook({ title: 'Book C', updatedAt: 3000 }),
];
const sorter = createBookSorter(LibrarySortByType.Updated, 'en');
const sorted = [...books].sort(sorter);
expect(sorted[0]!.title).toBe('Book B');
expect(sorted[1]!.title).toBe('Book A');
expect(sorted[2]!.title).toBe('Book C');
});
});
describe('grouping and sorting integration', () => {
it('should correctly group by series and sort groups by date read', () => {
const books = [
createMockBook({
hash: '1',
title: 'Old Series Book',
metadata: { series: 'Old Series', seriesIndex: 1 },
updatedAt: 1000,
}),
createMockBook({
hash: '2',
title: 'New Series Book 1',
metadata: { series: 'New Series', seriesIndex: 1 },
updatedAt: 3000,
}),
createMockBook({
hash: '3',
title: 'New Series Book 2',
metadata: { series: 'New Series', seriesIndex: 2 },
updatedAt: 2000,
}),
];
// Create groups
const items = createBookGroups(books, LibraryGroupByType.Series);
const groups = items.filter((item): item is BooksGroup => 'books' in item);
// Sort groups by updated (descending - most recent first)
const groupSorter = createGroupSorter(LibrarySortByType.Updated, 'en');
groups.sort((a, b) => groupSorter(a, b) * -1); // Descending
expect(groups[0]!.name).toBe('New Series'); // Most recent book at 3000
expect(groups[1]!.name).toBe('Old Series'); // Most recent book at 1000
// Sort within groups by seriesIndex
const withinSorter = createWithinGroupSorter(
LibraryGroupByType.Series,
LibrarySortByType.Updated,
'en',
);
groups.forEach((group) => group.books.sort(withinSorter));
expect(groups[0]!.books[0]!.metadata?.seriesIndex).toBe(1);
expect(groups[0]!.books[1]!.metadata?.seriesIndex).toBe(2);
});
it('should correctly group by author and sort within groups by title', () => {
const books = [
createMockBook({ hash: '1', title: 'Zebra', author: 'Author A' }),
createMockBook({ hash: '2', title: 'Apple', author: 'Author A' }),
createMockBook({ hash: '3', title: 'Mango', author: 'Author B' }),
];
// Create groups
const items = createBookGroups(books, LibraryGroupByType.Author);
const groups = items.filter((item): item is BooksGroup => 'books' in item);
// Sort groups alphabetically
const groupSorter = createGroupSorter(LibrarySortByType.Title, 'en');
groups.sort(groupSorter);
expect(groups[0]!.name).toBe('Author A');
expect(groups[1]!.name).toBe('Author B');
// Sort within groups by title
const withinSorter = createWithinGroupSorter(
LibraryGroupByType.Author,
LibrarySortByType.Title,
'en',
);
groups.forEach((group) => group.books.sort(withinSorter));
expect(groups[0]!.books[0]!.title).toBe('Apple');
expect(groups[0]!.books[1]!.title).toBe('Zebra');
});
it('should handle ascending/descending sort order', () => {
const books = [
createMockBook({
hash: '1',
title: 'Series A Book',
metadata: { series: 'Series A' },
updatedAt: 1000,
}),
createMockBook({
hash: '2',
title: 'Series B Book',
metadata: { series: 'Series B' },
updatedAt: 2000,
}),
];
const items = createBookGroups(books, LibraryGroupByType.Series);
const groups = items.filter((item): item is BooksGroup => 'books' in item);
const groupSorter = createGroupSorter(LibrarySortByType.Updated, 'en');
// Ascending (oldest first)
const ascending = [...groups].sort((a, b) => groupSorter(a, b) * 1);
expect(ascending[0]!.name).toBe('Series A');
// Descending (newest first)
const descending = [...groups].sort((a, b) => groupSorter(a, b) * -1);
expect(descending[0]!.name).toBe('Series B');
});
});
describe('ensureLibrarySortByType', () => {
it('should return valid sort type when value is valid', () => {
expect(ensureLibrarySortByType('title', LibrarySortByType.Updated)).toBe(
LibrarySortByType.Title,
);
expect(ensureLibrarySortByType('author', LibrarySortByType.Updated)).toBe(
LibrarySortByType.Author,
);
expect(ensureLibrarySortByType('updated', LibrarySortByType.Title)).toBe(
LibrarySortByType.Updated,
);
expect(ensureLibrarySortByType('created', LibrarySortByType.Updated)).toBe(
LibrarySortByType.Created,
);
expect(ensureLibrarySortByType('format', LibrarySortByType.Updated)).toBe(
LibrarySortByType.Format,
);
expect(ensureLibrarySortByType('published', LibrarySortByType.Updated)).toBe(
LibrarySortByType.Published,
);
});
it('should return fallback when value is null', () => {
expect(ensureLibrarySortByType(null, LibrarySortByType.Updated)).toBe(
LibrarySortByType.Updated,
);
});
it('should return fallback when value is undefined', () => {
expect(ensureLibrarySortByType(undefined, LibrarySortByType.Title)).toBe(
LibrarySortByType.Title,
);
});
it('should return fallback when value is invalid', () => {
expect(ensureLibrarySortByType('invalid', LibrarySortByType.Updated)).toBe(
LibrarySortByType.Updated,
);
expect(ensureLibrarySortByType('random', LibrarySortByType.Title)).toBe(
LibrarySortByType.Title,
);
});
it('should return fallback when value is empty string', () => {
expect(ensureLibrarySortByType('', LibrarySortByType.Updated)).toBe(LibrarySortByType.Updated);
});
});
describe('ensureLibraryGroupByType', () => {
it('should return valid group type when value is valid', () => {
expect(ensureLibraryGroupByType('none', LibraryGroupByType.Manual)).toBe(
LibraryGroupByType.None,
);
expect(ensureLibraryGroupByType('manual', LibraryGroupByType.None)).toBe(
LibraryGroupByType.Manual,
);
expect(ensureLibraryGroupByType('series', LibraryGroupByType.Manual)).toBe(
LibraryGroupByType.Series,
);
expect(ensureLibraryGroupByType('author', LibraryGroupByType.Manual)).toBe(
LibraryGroupByType.Author,
);
});
it('should return fallback when value is null', () => {
expect(ensureLibraryGroupByType(null, LibraryGroupByType.Manual)).toBe(
LibraryGroupByType.Manual,
);
});
it('should return fallback when value is undefined', () => {
expect(ensureLibraryGroupByType(undefined, LibraryGroupByType.Series)).toBe(
LibraryGroupByType.Series,
);
});
it('should return fallback when value is invalid', () => {
expect(ensureLibraryGroupByType('invalid', LibraryGroupByType.Manual)).toBe(
LibraryGroupByType.Manual,
);
expect(ensureLibraryGroupByType('random', LibraryGroupByType.Author)).toBe(
LibraryGroupByType.Author,
);
});
it('should return fallback when value is empty string', () => {
expect(ensureLibraryGroupByType('', LibraryGroupByType.Manual)).toBe(LibraryGroupByType.Manual);
});
});
describe('findGroupById', () => {
const createMockGroup = (overrides: Partial<BooksGroup> = {}): BooksGroup => ({
id: 'test-group',
name: 'Test Group',
displayName: 'Test Group',
books: [],
updatedAt: Date.now(),
...overrides,
});
it('should find group by id', () => {
const items: (Book | BooksGroup)[] = [
createMockBook({ hash: '1' }),
createMockGroup({ id: 'group-1', name: 'Group 1' }),
createMockGroup({ id: 'group-2', name: 'Group 2' }),
];
const found = findGroupById(items, 'group-2');
expect(found).toBeDefined();
expect(found!.name).toBe('Group 2');
});
it('should return undefined when group not found', () => {
const items: (Book | BooksGroup)[] = [
createMockBook({ hash: '1' }),
createMockGroup({ id: 'group-1', name: 'Group 1' }),
];
const found = findGroupById(items, 'non-existent');
expect(found).toBeUndefined();
});
it('should not match books', () => {
const items: (Book | BooksGroup)[] = [
createMockBook({ hash: 'group-1' }), // Book with hash matching a group id
createMockGroup({ id: 'group-2', name: 'Group 2' }),
];
const found = findGroupById(items, 'group-1');
expect(found).toBeUndefined();
});
});
describe('getGroupDisplayName', () => {
const createMockGroup = (overrides: Partial<BooksGroup> = {}): BooksGroup => ({
id: 'test-group',
name: 'Test Group',
displayName: 'Test Display Name',
books: [],
updatedAt: Date.now(),
...overrides,
});
it('should return displayName when available', () => {
const items: (Book | BooksGroup)[] = [
createMockGroup({ id: 'group-1', name: 'Name', displayName: 'Display Name' }),
];
expect(getGroupDisplayName(items, 'group-1')).toBe('Display Name');
});
it('should return name when displayName is empty', () => {
const items: (Book | BooksGroup)[] = [
createMockGroup({ id: 'group-1', name: 'Name', displayName: '' }),
];
expect(getGroupDisplayName(items, 'group-1')).toBe('Name');
});
it('should return undefined when group not found', () => {
const items: (Book | BooksGroup)[] = [createMockGroup({ id: 'group-1', name: 'Name' })];
expect(getGroupDisplayName(items, 'non-existent')).toBeUndefined();
});
});
+1 -1
View File
@@ -416,7 +416,7 @@ export default function AuthPage() {
Icon={FaDiscord}
label={_('Sign in with {{provider}}', { provider: 'Discord' })}
/>
<hr className='border-base-300 my-3 mt-6 w-64 border-t' />
<hr aria-hidden='true' className='border-base-300 my-3 mt-6 w-64 border-t' />
<div className='w-full'>
<Auth
supabaseClient={supabase}
@@ -3,8 +3,13 @@ import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PiPlus } from 'react-icons/pi';
import { Book, ReadingStatus } from '@/types/book';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import {
LibraryCoverFitType,
LibraryGroupByType,
LibrarySortByType,
LibraryViewModeType,
} from '@/types/settings';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useAutoFocus } from '@/hooks/useAutoFocus';
@@ -13,8 +18,15 @@ import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
import { createBookFilter, createBookSorter } from '../utils/libraryUtils';
import { formatTitle } from '@/utils/book';
import {
createBookFilter,
createBookGroups,
createBookSorter,
createGroupSorter,
createWithinGroupSorter,
ensureLibraryGroupByType,
ensureLibrarySortByType,
} from '../utils/libraryUtils';
import { eventDispatcher } from '@/utils/event';
import Alert from '@/components/Alert';
@@ -67,8 +79,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const groupId = searchParams?.get('group') || '';
const queryTerm = searchParams?.get('q') || null;
const viewMode = searchParams?.get('view') || settings.libraryViewMode;
const sortBy = searchParams?.get('sort') || settings.librarySortBy;
const sortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy);
const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
const groupBy = ensureLibraryGroupByType(searchParams?.get('groupBy'), settings.libraryGroupBy);
const coverFit = searchParams?.get('cover') || settings.libraryCoverFit;
const [loading, setLoading] = useState(false);
@@ -102,8 +115,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}
});
if (params.get('sort') === 'updated') params.delete('sort');
if (params.get('sort') === LibrarySortByType.Updated) params.delete('sort');
if (params.get('order') === 'desc') params.delete('order');
if (params.get('groupBy') === LibraryGroupByType.Manual) params.delete('groupBy');
if (params.get('cover') === 'crop') params.delete('cover');
if (params.get('view') === 'grid') params.delete('view');
@@ -123,13 +137,33 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}, [libraryBooks, queryTerm]);
const currentBookshelfItems = useMemo(() => {
const groupName = getGroupName(groupId) || '';
if (groupId && !groupName) {
return [];
if (groupBy === LibraryGroupByType.Manual) {
// Use existing generateBookshelfItems for manual mode
const groupName = getGroupName(groupId) || '';
if (groupId && !groupName) {
return [];
}
return generateBookshelfItems(filteredBooks, groupName);
} else {
// Use new createBookGroups for series/author/none modes
const allItems = createBookGroups(filteredBooks, groupBy);
// If navigating into a specific group, show only that group's books
if (groupId) {
const targetGroup = allItems.find(
(item): item is BooksGroup => 'books' in item && item.id === groupId,
);
if (targetGroup) {
// Return the books from the target group as individual items
return targetGroup.books;
}
// Group not found, return empty
return [];
}
return allItems;
}
const items = generateBookshelfItems(filteredBooks, groupName);
return items;
}, [filteredBooks, groupId, getGroupName]);
}, [filteredBooks, groupBy, groupId, getGroupName]);
useEffect(() => {
if (groupId && currentBookshelfItems.length === 0) {
@@ -140,24 +174,34 @@ const Bookshelf: React.FC<BookshelfProps> = ({
}, [searchParams, groupId, currentBookshelfItems.length, updateUrlParams]);
const sortedBookshelfItems = useMemo(() => {
const bookSorter = createBookSorter(sortBy, uiLanguage);
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
return currentBookshelfItems.sort((a, b) => {
if (sortBy === 'updated') {
return (
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
);
} else if ('name' in a || 'name' in b) {
const aName = 'name' in a ? a.name : formatTitle(a.title);
const bName = 'name' in b ? b.name : formatTitle(b.title);
return aName.localeCompare(bName, uiLanguage || navigator.language) * sortOrderMultiplier;
} else if (!('name' in a || 'name' in b)) {
return bookSorter(a, b) * sortOrderMultiplier;
} else {
return 0;
}
// Separate into ungrouped books and groups
const ungroupedBooks = currentBookshelfItems.filter((item): item is Book => 'format' in item);
const groups = currentBookshelfItems.filter((item): item is BooksGroup => 'books' in item);
// Sort groups by aggregate value
const groupSorter = createGroupSorter(sortBy, uiLanguage);
groups.sort((a, b) => groupSorter(a, b) * sortOrderMultiplier);
// Sort books within each group
const withinGroupSorter = createWithinGroupSorter(groupBy, sortBy, uiLanguage);
groups.forEach((group) => {
group.books.sort((a, b) => withinGroupSorter(a, b) * sortOrderMultiplier);
});
}, [sortOrder, sortBy, uiLanguage, currentBookshelfItems]);
// Sort ungrouped books - use within-group sorter if we're inside a group
// (for series, this ensures books are sorted by series index)
if (groupId && groupBy !== LibraryGroupByType.Manual && groupBy !== LibraryGroupByType.None) {
ungroupedBooks.sort((a, b) => withinGroupSorter(a, b) * sortOrderMultiplier);
} else {
const bookSorter = createBookSorter(sortBy, uiLanguage);
ungroupedBooks.sort((a, b) => bookSorter(a, b) * sortOrderMultiplier);
}
// Return groups first, then ungrouped books
return [...groups, ...ungroupedBooks];
}, [sortOrder, sortBy, groupBy, groupId, uiLanguage, currentBookshelfItems]);
useEffect(() => {
if (isImportingBook.current) return;
@@ -0,0 +1,59 @@
import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { MdArrowBack } from 'react-icons/md';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { navigateToLibrary } from '@/utils/nav';
import { LibraryGroupByType } from '@/types/settings';
interface GroupHeaderProps {
groupBy: LibraryGroupByType;
groupName: string;
}
/**
* Header component displayed when viewing books inside a series or author group.
* Shows the group type, group name, and a back button to return to the main bookshelf.
*/
const GroupHeader: React.FC<GroupHeaderProps> = ({ groupBy, groupName }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const iconSize = useResponsiveSize(20);
const handleBack = () => {
const params = new URLSearchParams(searchParams?.toString());
params.delete('group');
navigateToLibrary(router, params.toString());
};
// Get localized label for the group type
const getGroupTypeLabel = (): string => {
switch (groupBy) {
case LibraryGroupByType.Series:
return _('Series');
case LibraryGroupByType.Author:
return _('Author');
default:
return _('Group');
}
};
return (
<div className='flex items-center gap-2 px-4 py-2'>
<button
onClick={handleBack}
className='btn btn-ghost btn-sm h-8 min-h-8 px-2'
aria-label={_('Back to library')}
>
<MdArrowBack size={iconSize} />
</button>
<div className='flex items-center gap-2 overflow-hidden'>
<span className='text-neutral-content text-sm'>{getGroupTypeLabel()}:</span>
<span className='truncate text-base font-medium'>{groupName}</span>
</div>
</div>
);
};
export default GroupHeader;
@@ -254,7 +254,12 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
)
}
>
<ul className='flex flex-col'>
<ul
className='ms-0 flex flex-col before:hidden'
style={{
paddingInlineStart: `${iconSize}px`,
}}
>
<button onClick={handleUserProfile} className='w-full'>
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
</button>
@@ -349,7 +354,12 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
<>
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Advanced Settings')}>
<ul className='flex flex-col'>
<ul
className='ms-0 flex flex-col before:hidden'
style={{
paddingInlineStart: `${iconSize}px`,
}}
>
<MenuItem
label={_('Change Data Location')}
noIcon={!appService?.isAndroidApp}
@@ -3,7 +3,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { LibraryCoverFitType, LibrarySortByType, LibraryViewModeType } from '@/types/settings';
import {
LibraryCoverFitType,
LibraryViewModeType,
LibraryGroupByType,
LibrarySortByType,
} from '@/types/settings';
import { saveSysSettings } from '@/helpers/settings';
import { navigateToLibrary } from '@/utils/nav';
import NumberInput from '@/components/settings/NumberInput';
@@ -22,11 +27,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
const { settings } = useSettingsStore();
const viewMode = settings.libraryViewMode;
const sortBy = settings.librarySortBy;
const isAscending = settings.librarySortAscending;
const coverFit = settings.libraryCoverFit;
const autoColumns = settings.libraryAutoColumns;
const columns = settings.libraryColumns;
const groupBy = settings.libraryGroupBy;
const sortBy = settings.librarySortBy;
const isAscending = settings.librarySortAscending;
const viewOptions = [
{ label: _('List'), value: 'list' },
@@ -38,13 +44,20 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
{ label: _('Fit'), value: 'fit' },
];
const groupByOptions = [
{ label: _('None'), value: LibraryGroupByType.None },
{ label: _('Manual'), value: LibraryGroupByType.Manual },
{ label: _('Series'), value: LibraryGroupByType.Series },
{ label: _('Author'), value: LibraryGroupByType.Author },
];
const sortByOptions = [
{ label: _('Title'), value: 'title' },
{ label: _('Author'), value: 'author' },
{ label: _('Format'), value: 'format' },
{ label: _('Date Read'), value: 'updated' },
{ label: _('Date Added'), value: 'created' },
{ label: _('Date Published'), value: 'published' },
{ label: _('Title'), value: LibrarySortByType.Title },
{ label: _('Author'), value: LibrarySortByType.Author },
{ label: _('Format'), value: LibrarySortByType.Format },
{ label: _('Date Read'), value: LibrarySortByType.Updated },
{ label: _('Date Added'), value: LibrarySortByType.Created },
{ label: _('Date Published'), value: LibrarySortByType.Published },
];
const sortingOptions = [
@@ -54,7 +67,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
const handleSetViewMode = async (value: LibraryViewModeType) => {
await saveSysSettings(envConfig, 'libraryViewMode', value);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('view', value);
@@ -63,7 +75,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
const handleToggleCropCovers = async (value: LibraryCoverFitType) => {
await saveSysSettings(envConfig, 'libraryCoverFit', value);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('cover', value);
@@ -80,9 +91,22 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
await saveSysSettings(envConfig, 'libraryAutoColumns', false);
};
const handleSetGroupBy = async (value: LibraryGroupByType) => {
await saveSysSettings(envConfig, 'libraryGroupBy', value);
const params = new URLSearchParams(searchParams?.toString());
if (value === LibraryGroupByType.Manual) {
params.delete('groupBy');
} else {
params.set('groupBy', value);
}
// Clear group navigation when changing groupBy mode
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
};
const handleSetSortBy = async (value: LibrarySortByType) => {
await saveSysSettings(envConfig, 'librarySortBy', value);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('sort', value);
@@ -91,7 +115,6 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
const handleSetSortAscending = async (value: boolean) => {
await saveSysSettings(envConfig, 'librarySortAscending', value);
setIsDropdownOpen?.(false);
const params = new URLSearchParams(searchParams?.toString());
params.set('order', value ? 'asc' : 'desc');
@@ -103,6 +126,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
onCancel={() => setIsDropdownOpen?.(false)}
>
{/* View Mode */}
{viewOptions.map((option) => (
<MenuItem
key={option.value}
@@ -110,8 +134,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
buttonClass='h-8'
toggled={viewMode === option.value}
onClick={() => handleSetViewMode(option.value as LibraryViewModeType)}
transient
/>
))}
{/* Columns */}
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Columns')} buttonClass='h-8' labelClass='text-sm sm:text-xs' disabled />
<MenuItem
@@ -133,6 +160,8 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
}
onClick={() => handleToggleAutoColumns()}
/>
{/* Book Covers */}
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem
label={_('Book Covers')}
@@ -147,34 +176,54 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
buttonClass='h-8'
toggled={coverFit === option.value}
onClick={() => handleToggleCropCovers(option.value as LibraryCoverFitType)}
transient
/>
))}
{/* Group By - Collapsible */}
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem
label={_('Sort by...')}
buttonClass='h-8'
labelClass='text-sm sm:text-xs'
disabled
/>
{sortByOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
toggled={sortBy === option.value}
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
/>
))}
<MenuItem label={_('Group by...')} detailsOpen={true} buttonClass='py-[4px]'>
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
{groupByOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
toggled={groupBy === option.value}
onClick={() => handleSetGroupBy(option.value as LibraryGroupByType)}
transient
/>
))}
</ul>
</MenuItem>
{/* Sort By - Collapsible */}
<hr aria-hidden='true' className='border-base-200 my-1' />
{sortingOptions.map((option) => (
<MenuItem
key={option.value.toString()}
label={option.label}
buttonClass='h-8'
toggled={isAscending === option.value}
onClick={() => handleSetSortAscending(option.value)}
/>
))}
<MenuItem label={_('Sort by...')} detailsOpen={false} buttonClass='py-[4px]'>
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
{sortByOptions.map((option) => (
<MenuItem
key={option.value}
label={option.label}
buttonClass='h-8'
toggled={sortBy === option.value}
onClick={() => handleSetSortBy(option.value as LibrarySortByType)}
transient
/>
))}
<hr aria-hidden='true' className='border-base-200 my-1' />
{sortingOptions.map((option) => (
<MenuItem
key={option.value.toString()}
label={option.label}
buttonClass='h-8'
toggled={isAscending === option.value}
onClick={() => handleSetSortAscending(option.value)}
transient
/>
))}
</ul>
</MenuItem>
</Menu>
);
};
+48 -1
View File
@@ -50,6 +50,7 @@ import {
tauriQuitApp,
} from '@/utils/window';
import { LibraryGroupByType } from '@/types/settings';
import { BookMetadata } from '@/libs/document';
import { AboutWindow } from '@/components/AboutWindow';
import { BookDetailModal } from '@/components/metadata';
@@ -59,10 +60,16 @@ import { MigrateDataWindow } from './components/MigrateDataWindow';
import { useDragDropImport } from './hooks/useDragDropImport';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { Toast } from '@/components/Toast';
import { getBreadcrumbs } from './utils/libraryUtils';
import {
createBookGroups,
ensureLibraryGroupByType,
findGroupById,
getBreadcrumbs,
} from './utils/libraryUtils';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import Bookshelf from './components/Bookshelf';
import GroupHeader from './components/GroupHeader';
import useShortcuts from '@/hooks/useShortcuts';
import DropIndicator from '@/components/DropIndicator';
import SettingsDialog from '@/components/settings/SettingsDialog';
@@ -110,6 +117,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const [isSelectNone, setIsSelectNone] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [currentGroupPath, setCurrentGroupPath] = useState<string | undefined>(undefined);
const [currentSeriesAuthorGroup, setCurrentSeriesAuthorGroup] = useState<{
groupBy: typeof LibraryGroupByType.Series | typeof LibraryGroupByType.Author;
groupName: string;
} | null>(null);
const [booksTransferProgress, setBooksTransferProgress] = useState<{
[key: string]: number | null;
}>({});
@@ -379,6 +390,36 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
setCurrentGroupPath(groupName);
}, [libraryBooks, searchParams, getGroupName]);
// Track current series/author group for navigation header
useEffect(() => {
const groupId = searchParams?.get('group') || '';
const groupByParam = searchParams?.get('groupBy');
const groupBy = ensureLibraryGroupByType(groupByParam, settings.libraryGroupBy);
if (
groupId &&
(groupBy === LibraryGroupByType.Series || groupBy === LibraryGroupByType.Author)
) {
// Find the group to get its name
const allGroups = createBookGroups(
libraryBooks.filter((b) => !b.deletedAt),
groupBy,
);
const targetGroup = findGroupById(allGroups, groupId);
if (targetGroup) {
setCurrentSeriesAuthorGroup({
groupBy,
groupName: targetGroup.displayName || targetGroup.name,
});
} else {
setCurrentSeriesAuthorGroup(null);
}
} else {
setCurrentSeriesAuthorGroup(null);
}
}, [libraryBooks, searchParams, settings.libraryGroupBy]);
useEffect(() => {
if (demoBooks.length > 0 && libraryLoaded) {
const newLibrary = [...libraryBooks];
@@ -804,6 +845,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
</div>
</div>
)}
{currentSeriesAuthorGroup && (
<GroupHeader
groupBy={currentSeriesAuthorGroup.groupBy}
groupName={currentSeriesAuthorGroup.groupName}
/>
)}
{showBookshelf &&
(libraryBooks.some((book) => !book.deletedAt) ? (
<OverlayScrollbarsComponent
@@ -1,5 +1,63 @@
import { Book } from '@/types/book';
import { Book, BooksGroup } from '@/types/book';
import { LibraryGroupByType, LibrarySortByType } from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
import { md5Fingerprint } from '@/utils/md5';
/** Valid sort types for the library */
const VALID_SORT_TYPES: LibrarySortByType[] = Object.values(LibrarySortByType);
/** Valid group by types for the library */
const VALID_GROUP_BY_TYPES: LibraryGroupByType[] = Object.values(LibraryGroupByType);
/**
* Safely cast a query parameter to LibrarySortByType with fallback.
* Returns the value if valid, otherwise returns the fallback.
*/
export const ensureLibrarySortByType = (
value: string | null | undefined,
fallback: LibrarySortByType,
): LibrarySortByType => {
if (value && VALID_SORT_TYPES.includes(value as LibrarySortByType)) {
return value as LibrarySortByType;
}
return fallback;
};
/**
* Safely cast a query parameter to LibraryGroupByType with fallback.
* Returns the value if valid, otherwise returns the fallback.
*/
export const ensureLibraryGroupByType = (
value: string | null | undefined,
fallback: LibraryGroupByType,
): LibraryGroupByType => {
if (value && VALID_GROUP_BY_TYPES.includes(value as LibraryGroupByType)) {
return value as LibraryGroupByType;
}
return fallback;
};
/**
* Find a group by ID from a list of bookshelf items.
* Works for both manual groups and series/author groups.
*/
export const findGroupById = (
items: (Book | BooksGroup)[],
groupId: string,
): BooksGroup | undefined => {
return items.find((item): item is BooksGroup => 'books' in item && item.id === groupId);
};
/**
* Get the display name for a group, useful for breadcrumbs.
*/
export const getGroupDisplayName = (
items: (Book | BooksGroup)[],
groupId: string,
): string | undefined => {
const group = findGroupById(items, groupId);
return group?.displayName || group?.name;
};
export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
if (!queryTerm) return true;
@@ -33,21 +91,21 @@ export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book, b: Book) => {
switch (sortBy) {
case 'title':
case LibrarySortByType.Title:
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
case LibrarySortByType.Author:
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
case LibrarySortByType.Updated:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case 'created':
case LibrarySortByType.Created:
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
case LibrarySortByType.Format:
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
case 'published':
case LibrarySortByType.Published:
const aPublished = a.metadata?.published || '0001-01-01';
const bPublished = b.metadata?.published || '0001-01-01';
@@ -81,3 +139,229 @@ export const getBreadcrumbs = (currentPath: string) => {
path: segments.slice(0, index + 1).join('/'),
}));
};
/**
* Parse a combined author string into individual author names.
* Handles common separators like ", ", " & ", " and ".
*/
export const parseAuthors = (authorString: string): string[] => {
if (!authorString || !authorString.trim()) {
return [];
}
// Split by common separators: comma, ampersand, "and"
// Use regex to handle variations with different spacing
const authors = authorString
.split(/\s*(?:,|&|\band\b)\s*/i)
.map((author) => author.trim())
.filter((author) => author.length > 0);
return authors;
};
/**
* Create groups from books based on the groupBy setting.
* Returns a mix of BooksGroup and ungrouped Book items.
*/
export const createBookGroups = (
books: Book[],
groupBy: LibraryGroupByType,
): (Book | BooksGroup)[] => {
// Filter out deleted books
const activeBooks = books.filter((book) => !book.deletedAt);
if (groupBy === LibraryGroupByType.None) {
return activeBooks;
}
if (groupBy === LibraryGroupByType.Series) {
return createSeriesGroups(activeBooks);
}
if (groupBy === LibraryGroupByType.Author) {
return createAuthorGroups(activeBooks);
}
// 'manual' mode is handled separately by generateBookshelfItems
return activeBooks;
};
/**
* Group books by series metadata.
* Books without series appear as individual items.
*/
const createSeriesGroups = (books: Book[]): (Book | BooksGroup)[] => {
const seriesMap = new Map<string, Book[]>();
const ungroupedBooks: Book[] = [];
for (const book of books) {
const seriesName = book.metadata?.series?.trim();
if (seriesName) {
const existing = seriesMap.get(seriesName);
if (existing) {
existing.push(book);
} else {
seriesMap.set(seriesName, [book]);
}
} else {
ungroupedBooks.push(book);
}
}
const groups: BooksGroup[] = Array.from(seriesMap.entries()).map(([seriesName, seriesBooks]) => ({
id: md5Fingerprint(`series:${seriesName}`),
name: seriesName,
displayName: seriesName,
books: seriesBooks,
updatedAt: Math.max(...seriesBooks.map((b) => b.updatedAt)),
}));
return [...groups, ...ungroupedBooks];
};
/**
* Group books by author.
* Books with multiple authors appear in ALL matching author groups.
* Books without author appear as individual items.
*/
const createAuthorGroups = (books: Book[]): (Book | BooksGroup)[] => {
const authorMap = new Map<string, Book[]>();
const ungroupedBooks: Book[] = [];
for (const book of books) {
const authorString = book.author?.trim();
if (!authorString) {
ungroupedBooks.push(book);
continue;
}
const authors = parseAuthors(authorString);
if (authors.length === 0) {
ungroupedBooks.push(book);
continue;
}
// Add book to each author's group
for (const author of authors) {
const existing = authorMap.get(author);
if (existing) {
existing.push(book);
} else {
authorMap.set(author, [book]);
}
}
}
const groups: BooksGroup[] = Array.from(authorMap.entries()).map(([authorName, authorBooks]) => ({
id: md5Fingerprint(`author:${authorName}`),
name: authorName,
displayName: authorName,
books: authorBooks,
updatedAt: Math.max(...authorBooks.map((b) => b.updatedAt)),
}));
return [...groups, ...ungroupedBooks];
};
/**
* Create a sorter for books within a group.
* For series groups: sort by seriesIndex first, then by global sort for items without index.
* For author groups: follow global sort setting.
*/
export const createWithinGroupSorter =
(groupBy: LibraryGroupByType, sortBy: LibrarySortByType, uiLanguage: string) =>
(a: Book, b: Book): number => {
if (groupBy === LibraryGroupByType.Series) {
const aIndex = a.metadata?.seriesIndex;
const bIndex = b.metadata?.seriesIndex;
// Both have series index - sort by index
if (aIndex != null && bIndex != null) {
return aIndex - bIndex;
}
// Only one has series index - the one with index comes first
if (aIndex != null) return -1;
if (bIndex != null) return 1;
// Neither has series index - fall back to global sort
return createBookSorter(sortBy, uiLanguage)(a, b);
}
// For author and other groupings, use global sort
return createBookSorter(sortBy, uiLanguage)(a, b);
};
/**
* Get the aggregate sort value from a group for sorting groups.
*/
export const getGroupSortValue = (
group: BooksGroup,
sortBy: LibrarySortByType,
): number | string => {
const books = group.books;
if (books.length === 0) {
return sortBy === LibrarySortByType.Title ||
sortBy === LibrarySortByType.Author ||
sortBy === LibrarySortByType.Format
? group.name
: 0;
}
switch (sortBy) {
case LibrarySortByType.Title:
case LibrarySortByType.Author:
case LibrarySortByType.Format:
// For text-based sorts, use the group name
// This isn't perfect, especially with the
return group.name;
case LibrarySortByType.Updated:
// Return the most recent updatedAt
return Math.max(...books.map((b) => b.updatedAt));
case LibrarySortByType.Created:
// Return the most recent createdAt
return Math.max(...books.map((b) => b.createdAt));
case LibrarySortByType.Published: {
// Return the most recent published date
const publishedDates = books
.map((b) => b.metadata?.published)
.filter((d): d is string => !!d)
.map((d) => new Date(d).getTime())
.filter((t) => !isNaN(t));
return publishedDates.length > 0 ? Math.max(...publishedDates) : 0;
}
default:
return Math.max(...books.map((b) => b.updatedAt));
}
};
/**
* Create a sorter for groups themselves based on sort criteria.
*/
export const createGroupSorter =
(sortBy: LibrarySortByType, uiLanguage: string) =>
(a: BooksGroup, b: BooksGroup): number => {
const aValue = getGroupSortValue(a, sortBy);
const bValue = getGroupSortValue(b, sortBy);
// String comparison for text-based sorts
if (typeof aValue === 'string' && typeof bValue === 'string') {
return aValue.localeCompare(bValue, uiLanguage || navigator.language);
}
// Numeric comparison for date-based sorts
if (typeof aValue === 'number' && typeof bValue === 'number') {
return aValue - bValue;
}
return 0;
};
@@ -159,7 +159,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
) : (
<MenuItem label={_('Enter Parallel Read')} onClick={handleSetParallel} />
))}
<hr className='border-base-200 my-1' />
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('KOReader Sync')} onClick={showKoSyncSettingsWindow} />
{settings.kosync.enabled && (
<>
@@ -169,7 +169,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
)}
{appService?.isDesktopApp && (
<>
<hr className='border-base-200 my-1' />
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem
label={_('Show on Discord')}
tooltip={_("Display what I'm reading on Discord")}
@@ -178,9 +178,9 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
/>
</>
)}
<hr className='border-base-200 my-1' />
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Proofread')} onClick={showProofreadRulesWindow} />
<hr className='border-base-200 my-1' />
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
<MenuItem
label={_('Sort TOC by Page')}
@@ -188,7 +188,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
onClick={handleToggleSortTOC}
/>
<MenuItem label={_('Reload Page')} shortcut='Shift+R' onClick={handleReloadPage} />
<hr className='border-base-200 my-1' />
<hr aria-hidden='true' className='border-base-200 my-1' />
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
</Menu>
@@ -64,7 +64,7 @@ const SearchOptions: React.FC<SearchOptionsProps> = ({
isActive={searchConfig.scope === 'section'}
onClick={() => updateConfig('scope', 'section')}
/>
<hr className='border-base-200 my-1' />
<hr aria-hidden='true' className='border-base-200 my-1' />
<Option
label={_('Match Case')}
isActive={searchConfig.matchCase}
@@ -121,7 +121,7 @@ export const AboutWindow = () => {
</div>
</div>
<hr className='border-base-300 my-12 w-full sm:my-4' />
<hr aria-hidden='true' className='border-base-300 my-12 w-full sm:my-4' />
<div
className='flex flex-1 flex-col items-center justify-start gap-2 px-4 text-center'
+3 -1
View File
@@ -20,6 +20,7 @@ interface MenuItemProps {
iconClassName?: string;
children?: React.ReactNode;
siblings?: React.ReactNode;
detailsOpen?: boolean;
onClick?: () => void;
setIsDropdownOpen?: (isOpen: boolean) => void;
}
@@ -39,6 +40,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
iconClassName,
children,
siblings,
detailsOpen = false,
onClick,
setIsDropdownOpen,
}) => {
@@ -106,7 +108,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
<ul className='menu rounded-box m-0 p-0' role='menuitem' tabIndex={-1}>
<li aria-label={label}>
<details>
<details open={detailsOpen}>
<summary
className={clsx(
'hover:bg-base-300 text-base-content cursor-pointer rounded-md p-1 py-[10px] pr-3',
+9 -2
View File
@@ -15,7 +15,13 @@ import {
ViewConfig,
ViewSettings,
} from '@/types/book';
import { KOSyncSettings, ReadSettings, SystemSettings } from '@/types/settings';
import {
KOSyncSettings,
LibraryGroupByType,
LibrarySortByType,
ReadSettings,
SystemSettings,
} from '@/types/settings';
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota';
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
import { stubTranslation as _ } from '@/utils/misc';
@@ -75,8 +81,9 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
telemetryEnabled: true,
discordRichPresenceEnabled: false,
libraryViewMode: 'grid',
librarySortBy: 'updated',
librarySortBy: LibrarySortByType.Updated,
librarySortAscending: false,
libraryGroupBy: LibraryGroupByType.Manual,
libraryCoverFit: 'crop',
libraryAutoColumns: true,
libraryColumns: 6,
+22 -8
View File
@@ -8,16 +8,29 @@ import type { NotebookTab } from '@/store/notebookStore';
export type ThemeType = 'light' | 'dark' | 'auto';
export type LibraryViewModeType = 'grid' | 'list';
export type LibrarySortByType =
| 'title'
| 'author'
| 'updated'
| 'created'
| 'size'
| 'format'
| 'published';
export const LibrarySortByType = {
Title: 'title',
Author: 'author',
Updated: 'updated',
Created: 'created',
Size: 'size',
Format: 'format',
Published: 'published',
} as const;
export type LibrarySortByType = (typeof LibrarySortByType)[keyof typeof LibrarySortByType];
export type LibraryCoverFitType = 'crop' | 'fit';
export const LibraryGroupByType = {
None: 'none',
Manual: 'manual',
Series: 'series',
Author: 'author',
} as const;
export type LibraryGroupByType = (typeof LibraryGroupByType)[keyof typeof LibraryGroupByType];
export type KOSyncChecksumMethod = 'binary' | 'filename';
export type KOSyncStrategy = 'prompt' | 'silent' | 'send' | 'receive';
@@ -75,6 +88,7 @@ export interface SystemSettings {
libraryViewMode: LibraryViewModeType;
librarySortBy: LibrarySortByType;
librarySortAscending: boolean;
libraryGroupBy: LibraryGroupByType;
libraryCoverFit: LibraryCoverFitType;
libraryAutoColumns: boolean;
libraryColumns: number;