forked from akai/readest
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user