From 71b075dfa82cb09b6db870f8d12b891d0f243347 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 13 Feb 2025 18:45:57 +0100 Subject: [PATCH] feat: organize the library with groups (#368) --- .../public/locales/de/translation.json | 7 +- .../public/locales/el/translation.json | 7 +- .../public/locales/es/translation.json | 7 +- .../public/locales/fr/translation.json | 7 +- .../public/locales/hi/translation.json | 7 +- .../public/locales/id/translation.json | 7 +- .../public/locales/it/translation.json | 7 +- .../public/locales/ja/translation.json | 7 +- .../public/locales/ko/translation.json | 7 +- .../public/locales/pl/translation.json | 7 +- .../public/locales/pt/translation.json | 7 +- .../public/locales/ru/translation.json | 7 +- .../public/locales/tr/translation.json | 7 +- .../public/locales/uk/translation.json | 7 +- .../public/locales/vi/translation.json | 7 +- .../public/locales/zh-CN/translation.json | 7 +- .../public/locales/zh-TW/translation.json | 7 +- .../src-tauri/src/traffic_light_plugin.rs | 2 +- .../src/app/library/components/BookCover.tsx | 57 ++++ .../src/app/library/components/BookItem.tsx | 76 ++--- .../src/app/library/components/Bookshelf.tsx | 265 +++++++---------- .../app/library/components/BookshelfItem.tsx | 274 ++++++++++++++++++ .../src/app/library/components/GroupItem.tsx | 56 ++-- .../app/library/components/GroupingModal.tsx | 238 +++++++++++++++ .../app/library/components/LibraryHeader.tsx | 47 ++- apps/readest-app/src/app/library/page.tsx | 8 +- apps/readest-app/src/components/Spinner.tsx | 6 +- apps/readest-app/src/services/constants.ts | 2 + apps/readest-app/src/styles/globals.css | 12 + apps/readest-app/src/types/book.ts | 10 +- apps/readest-app/src/types/records.ts | 3 +- apps/readest-app/src/utils/md5.ts | 8 + apps/readest-app/src/utils/nav.ts | 8 +- apps/readest-app/src/utils/transform.ts | 12 +- 34 files changed, 931 insertions(+), 272 deletions(-) create mode 100644 apps/readest-app/src/app/library/components/BookCover.tsx create mode 100644 apps/readest-app/src/app/library/components/BookshelfItem.tsx create mode 100644 apps/readest-app/src/app/library/components/GroupingModal.tsx diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 5ae4bd8d..158ddce1 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Buchstabenabstand", "Text Indent": "Texteinzug", "Paragraph Margin": "Absatzrand", - "Override Book Layout": "Buch-Layout überschreiben" + "Override Book Layout": "Buch-Layout überschreiben", + "Add to Group": "Zur Gruppe hinzufügen", + "Untitled Group": "Unbenannte Gruppe", + "Group Books": "Bücher gruppieren", + "Remove From Group": "Aus Gruppe entfernen", + "Create New Group": "Neue Gruppe erstellen" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index be864201..4742f51d 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Διάκενο γραμμάτων", "Text Indent": "Εσοχή κειμένου", "Paragraph Margin": "Περιθώριο παραγράφου", - "Override Book Layout": "Παράκαμψη διάταξης βιβλίου" + "Override Book Layout": "Παράκαμψη διάταξης βιβλίου", + "Add to Group": "Προσθήκη στην ομάδα", + "Untitled Group": "Ομάδα χωρίς τίτλο", + "Group Books": "Ομαδοποίηση βιβλίων", + "Remove From Group": "Αφαίρεση από την ομάδα", + "Create New Group": "Δημιουργία νέας ομάδας" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index b95da8ec..5c56f7ad 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Espaciado de letras", "Text Indent": "Sangría de texto", "Paragraph Margin": "Margen de párrafo", - "Override Book Layout": "Sobrescribir diseño del libro" + "Override Book Layout": "Sobrescribir diseño del libro", + "Add to Group": "Añadir a grupo", + "Untitled Group": "Grupo sin título", + "Group Books": "Agrupar libros", + "Remove From Group": "Eliminar del grupo", + "Create New Group": "Crear nuevo grupo" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 6c884c31..c3935809 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Espacement des lettres", "Text Indent": "Retrait du texte", "Paragraph Margin": "Marge de paragraphe", - "Override Book Layout": "Remplacer la mise en page du livre" + "Override Book Layout": "Remplacer la mise en page du livre", + "Add to Group": "Ajouter au groupe", + "Untitled Group": "Groupe sans titre", + "Group Books": "Regrouper les livres", + "Remove From Group": "Retirer du groupe", + "Create New Group": "Créer un nouveau groupe" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index ab1741ba..bdbf81ab 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "अक्षर स्पेसिंग", "Text Indent": "टेक्स्ट इंडेंट", "Paragraph Margin": "पैराग्राफ मार्जिन", - "Override Book Layout": "पुस्तक लेआउट ओवरराइड करें" + "Override Book Layout": "पुस्तक लेआउट ओवरराइड करें", + "Add to Group": "समूह में जोड़ें", + "Untitled Group": "अभिनामित समूह", + "Group Books": "समूह पुस्तकें", + "Remove From Group": "समूह से हटाएं", + "Create New Group": "नया समूह बनाएं" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index a55ba5a8..ab48edce 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Spasi Huruf", "Text Indent": "Indentasi Teks", "Paragraph Margin": "Margin Paragraf", - "Override Book Layout": "Ganti Tata Letak Buku" + "Override Book Layout": "Ganti Tata Letak Buku", + "Add to Group": "Tambahkan ke Grup", + "Untitled Group": "Grup Tanpa Judul", + "Group Books": "Grupkan Buku", + "Remove From Group": "Hapus dari Grup", + "Create New Group": "Buat Grup Baru" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 375fde39..9493d53a 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Spaziatura lettere", "Text Indent": "Rientro testo", "Paragraph Margin": "Margine paragrafo", - "Override Book Layout": "Sovrascrivi layout libro" + "Override Book Layout": "Sovrascrivi layout libro", + "Add to Group": "Aggiungi al gruppo", + "Untitled Group": "Gruppo senza titolo", + "Group Books": "Raggruppa libri", + "Remove From Group": "Rimuovi dal", + "Create New Group": "Crea nuovo gruppo" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index d0be1e96..a1ac29a8 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "文字間隔", "Text Indent": "テキストインデント", "Paragraph Margin": "段落マージン", - "Override Book Layout": "書籍のレイアウトを上書き" + "Override Book Layout": "書籍のレイアウトを上書き", + "Add to Group": "グループに追加", + "Untitled Group": "無題のグループ", + "Group Books": "書籍をグループ化", + "Remove From Group": "グループから削除", + "Create New Group": "新しいグループを作成" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 12a624ca..6abb7b73 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "글자 간격", "Text Indent": "들여쓰기", "Paragraph Margin": "단락 여백", - "Override Book Layout": "책 레이아웃 덮어쓰기" + "Override Book Layout": "책 레이아웃 덮어쓰기", + "Add to Group": "그룹에 추가", + "Untitled Group": "제목 없는 그룹", + "Group Books": "책 그룹화", + "Remove From Group": "그룹에서 제거", + "Create New Group": "새 그룹 만들기" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index b5b08dc7..7b544a3e 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Odstęp między literami", "Text Indent": "Wcięcie tekstu", "Paragraph Margin": "Margines akapitu", - "Override Book Layout": "Nadpisz układ książki" + "Override Book Layout": "Nadpisz układ książki", + "Add to Group": "Dodaj do grupy", + "Untitled Group": "Grupa bez tytułu", + "Group Books": "Grupuj książki", + "Remove From Group": "Usuń z grupy", + "Create New Group": "Utwórz nową grupę" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 98ff3abb..7faf65a9 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Espaçamento de Letra", "Text Indent": "Recuo de Texto", "Paragraph Margin": "Margem de Parágrafo", - "Override Book Layout": "Sobrescrever Layout do Livro" + "Override Book Layout": "Sobrescrever Layout do Livro", + "Add to Group": "Adicionar ao Grupo", + "Untitled Group": "Grupo Sem Título", + "Group Books": "Agrupar Livros", + "Remove From Group": "Remover do Grupo", + "Create New Group": "Criar Novo Grupo" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 1990681e..a7674151 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Межбуквенный интервал", "Text Indent": "Отступ текста", "Paragraph Margin": "Отступ абзаца", - "Override Book Layout": "Переопределить макет книги" + "Override Book Layout": "Переопределить макет книги", + "Add to Group": "Добавить в группу", + "Untitled Group": "Группа без названия", + "Group Books": "Группировать книги", + "Remove From Group": "Удалить из группы", + "Create New Group": "Создать новую группу" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 60e3ed03..0ec64762 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Harf Aralığı", "Text Indent": "Metin Girintisi", "Paragraph Margin": "Paragraf Kenarı", - "Override Book Layout": "Kitap Düzenini Geçersiz Kıl" + "Override Book Layout": "Kitap Düzenini Geçersiz Kıl", + "Add to Group": "Gruba Ekle", + "Untitled Group": "Başlıksız Grup", + "Group Books": "Kitapları Grupla", + "Remove From Group": "Grubu Kaldır", + "Create New Group": "Yeni Grup Oluştur" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 5dc88b67..18990d1d 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Міжбуквений інтервал", "Text Indent": "Відступ тексту", "Paragraph Margin": "Відступ абзацу", - "Override Book Layout": "Перевизначити макет книги" + "Override Book Layout": "Перевизначити макет книги", + "Add to Group": "Додати до групи", + "Untitled Group": "Група без назви", + "Group Books": "Групувати книги", + "Remove From Group": "Видалити з групи", + "Create New Group": "Створити нову групу" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 7831f5be..fac535fa 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "Khoảng cách chữ", "Text Indent": "Thụt lề", "Paragraph Margin": "Lề đoạn", - "Override Book Layout": "Ghi đè bố cục sách" + "Override Book Layout": "Ghi đè bố cục sách", + "Add to Group": "Thêm vào nhóm", + "Untitled Group": "Nhóm không có tiêu đề", + "Group Books": "Nhóm sách", + "Remove From Group": "Xóa khỏi nhóm", + "Create New Group": "Tạo nhóm mới" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 97e02f40..f44835b5 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "字间距", "Text Indent": "首行缩进", "Paragraph Margin": "段间距", - "Override Book Layout": "覆盖版面布局" + "Override Book Layout": "覆盖版面布局", + "Add to Group": "添加到分组", + "Untitled Group": "无标题分组", + "Group Books": "分组书籍", + "Remove From Group": "从分组中移除", + "Create New Group": "创建新分组" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 321a8b63..8f8d0200 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -156,5 +156,10 @@ "Letter Spacing": "字間距", "Text Indent": "首行縮進", "Paragraph Margin": "段間距", - "Override Book Layout": "覆蓋版面佈局" + "Override Book Layout": "覆蓋版面佈局", + "Add to Group": "添加到組", + "Untitled Group": "無標題組", + "Group Books": "分組書籍", + "Remove From Group": "從組中移除", + "Create New Group": "創建新組" } diff --git a/apps/readest-app/src-tauri/src/traffic_light_plugin.rs b/apps/readest-app/src-tauri/src/traffic_light_plugin.rs index 2853b05e..5b8b133c 100644 --- a/apps/readest-app/src-tauri/src/traffic_light_plugin.rs +++ b/apps/readest-app/src-tauri/src/traffic_light_plugin.rs @@ -7,7 +7,7 @@ use tauri::{ }; // 0.8 const WINDOW_CONTROL_PAD_X: f64 = 10.0; -const WINDOW_CONTROL_PAD_Y: f64 = 20.0; +const WINDOW_CONTROL_PAD_Y: f64 = 22.0; struct UnsafeWindowHandle(*mut std::ffi::c_void); unsafe impl Send for UnsafeWindowHandle {} diff --git a/apps/readest-app/src/app/library/components/BookCover.tsx b/apps/readest-app/src/app/library/components/BookCover.tsx new file mode 100644 index 00000000..5e7e7196 --- /dev/null +++ b/apps/readest-app/src/app/library/components/BookCover.tsx @@ -0,0 +1,57 @@ +import clsx from 'clsx'; +import Image from 'next/image'; +import { Book } from '@/types/book'; +import { formatAuthors, formatTitle } from '@/utils/book'; + +interface BookCoverProps { + book: Book; + isPreview?: boolean; +} + +const BookCover: React.FC = ({ book, isPreview }) => { + return ( +
+ {book.title} { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible'); + }} + /> +
+
+ + {formatTitle(book.title)} + +
+
+
+ + {formatAuthors(book.author)} + +
+
+
+ ); +}; + +export default BookCover; diff --git a/apps/readest-app/src/app/library/components/BookItem.tsx b/apps/readest-app/src/app/library/components/BookItem.tsx index 7c3ab18a..d5d6f306 100644 --- a/apps/readest-app/src/app/library/components/BookItem.tsx +++ b/apps/readest-app/src/app/library/components/BookItem.tsx @@ -1,5 +1,4 @@ import clsx from 'clsx'; -import Image from 'next/image'; import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md'; import { CiCircleMore } from 'react-icons/ci'; import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/lia'; @@ -7,16 +6,14 @@ import { LiaCloudUploadAltSolid, LiaCloudDownloadAltSolid } from 'react-icons/li import { Book } from '@/types/book'; import { useEnv } from '@/context/EnvContext'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; -import { formatAuthors, formatTitle } from '@/utils/book'; import ReadingProgress from './ReadingProgress'; +import BookCover from './BookCover'; interface BookItemProps { book: Book; isSelectMode: boolean; selectedBooks: string[]; - clickedBookHash: string | null; transferProgress: number | null; - handleBookClick: (book: Book) => void; handleBookUpload: (book: Book) => void; handleBookDownload: (book: Book) => void; showBookDetailsModal: (book: Book) => void; @@ -27,9 +24,7 @@ const BookItem: React.FC = ({ book, isSelectMode, selectedBooks, - clickedBookHash, transferProgress, - handleBookClick, handleBookUpload, handleBookDownload, showBookDetailsModal, @@ -38,52 +33,33 @@ const BookItem: React.FC = ({ const iconSize15 = useResponsiveSize(15); const { appService } = useEnv(); + const stopEvent = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + return (
-
handleBookClick(book)}> -
- {book.title} { - (e.target as HTMLImageElement).style.display = 'none'; - (e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible'); - }} - /> -
+ + {selectedBooks.includes(book.hash) && ( +
+ )} + {isSelectMode && ( +
+ {selectedBooks.includes(book.hash) ? ( + + ) : ( + )} - > -
- {formatTitle(book.title)} -
-
-
- - {formatAuthors(book.author)} - -
- {(selectedBooks.includes(book.hash) || clickedBookHash === book.hash) && ( -
- )} - {isSelectMode && ( -
- {selectedBooks.includes(book.hash) ? ( - - ) : ( - - )} -
- )} -
+ )}
@@ -113,6 +89,10 @@ const BookItem: React.FC = ({ ) : ( - + +
)}
+ {showGroupingModal && ( +
+ { + setSelectedBooks([]); + setShowGroupingModal(false); + }} + onCancel={() => setShowGroupingModal(false)} + /> +
+ )} {showDeleteAlert && (
diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx new file mode 100644 index 00000000..0cea4926 --- /dev/null +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -0,0 +1,274 @@ +import { useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { navigateToLibrary, navigateToReader } from '@/utils/nav'; +import { useEnv } from '@/context/EnvContext'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import { Menu, MenuItem } from '@tauri-apps/api/menu'; +import { revealItemInDir } from '@tauri-apps/plugin-opener'; +import { getOSPlatform } from '@/utils/misc'; +import { getFilename } from '@/utils/book'; +import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; +import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os'; +import { Book, BookGroupType, BooksGroup } from '@/types/book'; +import BookItem from './BookItem'; +import GroupItem from './GroupItem'; + +export type BookshelfItem = Book | BooksGroup; + +export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => { + const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => { + if (book.deletedAt) return acc; + book.groupId = book.groupId || BOOK_UNGROUPED_ID; + book.groupName = book.groupName || BOOK_UNGROUPED_NAME; + const groupIndex = acc.findIndex((group) => group.id === book.groupId); + const booksGroup = acc[acc.findIndex((group) => group.id === book.groupId)]; + if (booksGroup) { + booksGroup.books.push(book); + booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt); + } else { + acc.push({ + id: book.groupId, + name: book.groupName, + books: [book], + updatedAt: book.updatedAt, + }); + } + return acc; + }, []); + groups.forEach((group) => { + group.books.sort((a, b) => b.updatedAt - a.updatedAt); + }); + const ungroupedBooks: Book[] = + groups.find((group) => group.name === BOOK_UNGROUPED_NAME)?.books || []; + const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== BOOK_UNGROUPED_NAME); + return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt); +}; + +export const generateGroupsList = (items: Book[]): BookGroupType[] => { + return items + .sort((a, b) => b.updatedAt - a.updatedAt) + .reduce((acc: BookGroupType[], item: Book) => { + if (item.deletedAt) return acc; + if ( + item.groupId && + item.groupName && + item.groupId !== BOOK_UNGROUPED_ID && + item.groupName !== BOOK_UNGROUPED_NAME && + !acc.find((group) => group.id === item.groupId) + ) { + acc.push({ id: item.groupId, name: item.groupName }); + } + return acc; + }, []) as BookGroupType[]; +}; + +interface BookshelfItemProps { + item: BookshelfItem; + isSelectMode: boolean; + selectedBooks: string[]; + transferProgress: number | null; + setLoading: React.Dispatch>; + toggleSelection: (hash: string) => void; + handleBookUpload: (book: Book) => void; + handleBookDownload: (book: Book) => void; + handleBookDelete: (book: Book) => void; + handleSetSelectMode: (selectMode: boolean) => void; + handleShowDetailsBook: (book: Book) => void; +} + +const BookshelfItem: React.FC = ({ + item, + isSelectMode, + selectedBooks, + transferProgress, + setLoading, + toggleSelection, + handleBookUpload, + handleBookDownload, + handleBookDelete, + handleSetSelectMode, + handleShowDetailsBook, +}) => { + const _ = useTranslation(); + const router = useRouter(); + const { envConfig, appService } = useEnv(); + const { settings } = useSettingsStore(); + const { updateBook } = useLibraryStore(); + + const longPressTimerRef = useRef | null>(null); + const longPressStartRef = useRef(null); + const longPressThreshold = 500; + + const showBookDetailsModal = async (book: Book) => { + if (await makeBookAvailable(book)) { + handleShowDetailsBook(book); + } + }; + + const makeBookAvailable = async (book: Book) => { + if (book.uploadedAt && !book.downloadedAt) { + let available = false; + try { + await handleBookDownload(book); + updateBook(envConfig, book); + available = true; + } finally { + return available; + } + } + return true; + }; + + const handleBookClick = async (book: Book) => { + if (!(await makeBookAvailable(book))) return; + + if (isSelectMode) { + toggleSelection(book.hash); + } else { + setTimeout(() => setLoading(true), 200); + navigateToReader(router, [book.hash]); + } + }; + + const handleGroupClick = (group: BooksGroup) => { + if (isSelectMode) { + toggleSelection(group.id); + } else { + navigateToLibrary(router, `group=${group.id}`, { scroll: false }); + } + }; + + const handleItemLongPress = (item: BookshelfItem) => { + if (!isSelectMode) { + handleSetSelectMode(true); + } + setTimeout(() => { + if ('format' in item) { + toggleSelection((item as Book).hash); + } else { + toggleSelection((item as BooksGroup).id); + } + }, 0); + }; + + const handleItemTouchStart = ( + _item: BookshelfItem, + event: React.TouchEvent | React.MouseEvent, + ) => { + longPressStartRef.current = event.timeStamp; + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + longPressTimerRef.current = setTimeout(() => { + longPressStartRef.current = null; + }, longPressThreshold); + }; + + const handleItemTouchEnd = (item: BookshelfItem, event: React.TouchEvent | React.MouseEvent) => { + if ( + longPressStartRef.current && + event.timeStamp - longPressStartRef.current < longPressThreshold + ) { + if ('format' in item) { + handleBookClick(item as Book); + } else { + handleGroupClick(item as BooksGroup); + } + } else { + handleItemLongPress(item); + } + longPressStartRef.current = null; + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + }; + + const handleItemContextMenu = (item: BookshelfItem, event: React.MouseEvent) => { + if (longPressStartRef.current) { + event.preventDefault(); + if ('format' in item) { + bookContextMenuHandler(item as Book, event); + } + } + longPressStartRef.current = null; + }; + + const bookContextMenuHandler = async (book: Book, e: React.MouseEvent) => { + if (!appService?.hasContextMenu) return; + e.preventDefault(); + e.stopPropagation(); + const osPlatform = getOSPlatform(); + const fileRevealLabel = + FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default; + const openBookMenuItem = await MenuItem.new({ + text: isSelectMode ? _('Select Book') : _('Open Book'), + action: async () => { + handleBookClick(book); + }, + }); + const showBookInFinderMenuItem = await MenuItem.new({ + text: _(fileRevealLabel), + action: async () => { + const folder = `${settings.localBooksDir}/${getFilename(book)}`; + revealItemInDir(folder); + }, + }); + const showBookDetailsMenuItem = await MenuItem.new({ + text: _('Show Book Details'), + action: async () => { + showBookDetailsModal(book); + }, + }); + const uploadBookMenuItem = await MenuItem.new({ + text: _('Upload Book'), + action: async () => { + handleBookUpload(book); + }, + }); + const deleteBookMenuItem = await MenuItem.new({ + text: _('Delete'), + action: async () => { + await handleBookDelete(book); + }, + }); + const menu = await Menu.new(); + menu.append(openBookMenuItem); + menu.append(showBookDetailsMenuItem); + menu.append(showBookInFinderMenuItem); + menu.append(uploadBookMenuItem); + menu.append(deleteBookMenuItem); + menu.popup(); + }; + + return ( +
handleItemTouchStart(item, event)} + onTouchEnd={(event) => handleItemTouchEnd(item, event)} + onMouseDown={(event) => handleItemTouchStart(item, event)} + onMouseUp={(event) => handleItemTouchEnd(item, event)} + onContextMenu={(event) => handleItemContextMenu(item, event)} + > +
+ {'format' in item ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default BookshelfItem; diff --git a/apps/readest-app/src/app/library/components/GroupItem.tsx b/apps/readest-app/src/app/library/components/GroupItem.tsx index ce64cd11..20de0d75 100644 --- a/apps/readest-app/src/app/library/components/GroupItem.tsx +++ b/apps/readest-app/src/app/library/components/GroupItem.tsx @@ -1,36 +1,42 @@ -import Image from 'next/image'; +import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md'; import { BooksGroup } from '@/types/book'; -import { formatAuthors, formatTitle } from '@/utils/book'; -import ReadingProgress from './ReadingProgress'; +import BookCover from './BookCover'; interface GroupItemProps { group: BooksGroup; + isSelectMode: boolean; + selectedBooks: string[]; } -const GroupItem: React.FC = ({ group }) => { +const GroupItem: React.FC = ({ group, isSelectMode, selectedBooks }) => { return ( -
- {group.books.map((book) => ( -
-
- {book.title -
-
-

{formatTitle(book.title)}

-

- {formatAuthors(book.author)} -

- -
+
+
+
+ {group.books.slice(0, 4).map((book) => ( +
+ +
+ ))}
- ))} -

{group.name}

+ {selectedBooks.includes(group.id) && ( +
+ )} + {isSelectMode && ( +
+ {selectedBooks.includes(group.id) ? ( + + ) : ( + + )} +
+ )} +
+
+

+ {group.name} +

+
); }; diff --git a/apps/readest-app/src/app/library/components/GroupingModal.tsx b/apps/readest-app/src/app/library/components/GroupingModal.tsx new file mode 100644 index 00000000..25d9b3c2 --- /dev/null +++ b/apps/readest-app/src/app/library/components/GroupingModal.tsx @@ -0,0 +1,238 @@ +import clsx from 'clsx'; +import React, { useEffect, useRef, useState } from 'react'; +import { MdCheck } from 'react-icons/md'; +import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi'; + +import { Book, BookGroupType } from '@/types/book'; +import { isMd5, md5Fingerprint } from '@/utils/md5'; +import { useEnv } from '@/context/EnvContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; +import { generateGroupsList } from './BookshelfItem'; + +interface GroupingModalProps { + libraryBooks: Book[]; + selectedBooks: string[]; + onConfirm: () => void; + onCancel: () => void; +} + +const GroupingModal: React.FC = ({ + libraryBooks, + selectedBooks, + onConfirm, + onCancel, +}) => { + const _ = useTranslation(); + const { appService } = useEnv(); + const { setLibrary } = useLibraryStore(); + const groupsList = generateGroupsList(libraryBooks); + + const [showInput, setShowInput] = useState(false); + const [editGroupName, setEditGroupName] = useState(_('Untitled Group')); + const [selectedGroup, setSelectedGroup] = useState(null); + const [newGroups, setNewGroups] = useState([]); + const [allGroups, setAllGroups] = useState(groupsList); + const editorRef = useRef(null); + + const iconSize = useResponsiveSize(16); + + const isSelectedBooksHasGroup = + selectedBooks.some((hash) => !isMd5(hash)) || + selectedBooks + .map((hash) => libraryBooks.find((book) => book.hash === hash)?.groupId) + .some((group) => group && group !== BOOK_UNGROUPED_NAME); + + const handleCreateGroup = () => { + setShowInput(true); + }; + + const handleRemoveFromGroup = () => { + selectedBooks.forEach((id) => { + for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) { + if ( + book && + book.groupId && + book.groupName && + book.groupId !== BOOK_UNGROUPED_ID && + book.groupName !== BOOK_UNGROUPED_NAME + ) { + book.groupId = undefined; + book.groupName = undefined; + book.updatedAt = Date.now(); + } + } + }); + setLibrary(libraryBooks); + appService?.saveLibraryBooks(libraryBooks); + onConfirm(); + }; + + const handleConfirmCreateGroup = () => { + const groupName = editGroupName.trim(); + if (groupName) { + const newGroup = { id: md5Fingerprint(groupName), name: groupName }; + const existingGroupIndex = newGroups.findIndex((group) => group.name === groupName); + if (existingGroupIndex > -1) { + newGroups.splice(existingGroupIndex, 1); + } + newGroups.push(newGroup); + setSelectedGroup(newGroup); + setNewGroups(newGroups); + for (const newGroup of newGroups) { + const existingGroupIndex = groupsList.findIndex((group) => group.id === newGroup.id); + if (existingGroupIndex > -1) { + groupsList.splice(existingGroupIndex, 1); + } + groupsList.unshift(newGroup); + } + setAllGroups(groupsList); + const untitledGroupPattern = new RegExp(`^${_('Untitled Group')}\\s*(\\d+)?$`); + const untitledGroupNumbers = groupsList + .map((group) => { + const match = group.name.match(untitledGroupPattern); + return match ? parseInt(match[1] || '0', 10) : null; + }) + .filter((num) => num !== null); + + const nextNumber = + untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1; + setEditGroupName(`${_('Untitled Group')} ${nextNumber}`); + setShowInput(false); + } + }; + + const handleToggleSelectGroup = (group: BookGroupType) => { + setSelectedGroup((prevGroup) => (prevGroup?.id === group.id ? null : group)); + }; + + const handleConfirmGrouping = () => { + selectedBooks.forEach((id) => { + for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) { + if (book && selectedGroup) { + book.groupId = selectedGroup.id; + book.groupName = selectedGroup.name; + book.updatedAt = Date.now(); + } + } + }); + setLibrary(libraryBooks); + appService?.saveLibraryBooks(libraryBooks); + onConfirm(); + }; + + useEffect(() => { + if (editorRef.current) { + editorRef.current.select(); + } + }, [showInput]); + + useEffect(() => { + const groupIds = selectedBooks + .map((id) => libraryBooks.find((book) => book.hash === id || book.groupId === id)?.groupId) + .filter((groupId) => groupId); + if (groupIds.length === 1) { + setSelectedGroup(groupsList.find((group) => group.id === groupIds[0]) || null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedBooks]); + + return ( +
+
+

{_('Group Books')}

+
+ {isSelectedBooksHasGroup && ( + + )} + +
+ {showInput && ( +
+ setEditGroupName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConfirmCreateGroup(); + e.stopPropagation(); + }} + className='input input-ghost w-full border-0 px-2 text-base !outline-none sm:text-sm' + /> + +
+ )} +
    + {allGroups.map((group, index) => ( + + ))} +
+
+ + +
+
+
+ ); +}; + +export default GroupingModal; diff --git a/apps/readest-app/src/app/library/components/LibraryHeader.tsx b/apps/readest-app/src/app/library/components/LibraryHeader.tsx index 44a15a1b..32c8db71 100644 --- a/apps/readest-app/src/app/library/components/LibraryHeader.tsx +++ b/apps/readest-app/src/app/library/components/LibraryHeader.tsx @@ -1,13 +1,16 @@ import clsx from 'clsx'; import React, { useEffect, useRef } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import { FaSearch } from 'react-icons/fa'; import { PiPlus } from 'react-icons/pi'; import { PiSelectionAllDuotone } from 'react-icons/pi'; import { MdOutlineMenu } from 'react-icons/md'; +import { MdArrowBackIosNew } from 'react-icons/md'; import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; +import { navigateToLibrary } from '@/utils/nav'; import useTrafficLight from '@/hooks/useTrafficLight'; import WindowButtons from '@/components/WindowButtons'; import Dropdown from '@/components/Dropdown'; @@ -26,10 +29,13 @@ const LibraryHeader: React.FC = ({ onToggleSelectMode, }) => { const _ = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); const { appService } = useEnv(); const { isTrafficLightVisible } = useTrafficLight(); const headerRef = useRef(null); const iconSize16 = useResponsiveSize(16); + const iconSize22 = useResponsiveSize(22); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -44,6 +50,7 @@ const LibraryHeader: React.FC = ({ }, [onToggleSelectMode]); const windowButtonVisible = appService?.appPlatform !== 'web' && !isTrafficLightVisible; + const isInGroupView = !!searchParams?.get('group'); return (
= ({ >
- - - - + {isInGroupView && ( + + )} +
+ + + + +
{ setIsSelectMode(!isSelectMode); }; + const handleSetSelectMode = (selectMode: boolean) => { + setIsSelectMode(selectMode); + }; + const handleShowDetailsBook = (book: Book) => { setShowDetailsBook(book); }; @@ -380,7 +384,7 @@ const LibraryPage = () => {
@@ -391,7 +395,9 @@ const LibraryPage = () => { handleBookUpload={handleBookUpload} handleBookDownload={handleBookDownload} handleBookDelete={handleBookDelete} + handleSetSelectMode={handleSetSelectMode} handleShowDetailsBook={handleShowDetailsBook} + handleToggleSelectMode={handleToggleSelectMode} booksTransferProgress={booksTransferProgress} />
diff --git a/apps/readest-app/src/components/Spinner.tsx b/apps/readest-app/src/components/Spinner.tsx index c8a0cd65..445c5d6a 100644 --- a/apps/readest-app/src/components/Spinner.tsx +++ b/apps/readest-app/src/components/Spinner.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import { useTranslation } from '@/hooks/useTranslation'; @@ -9,7 +10,10 @@ const Spinner: React.FC<{ return (
diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index ca8b9707..06c2b252 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -15,6 +15,8 @@ export const CLOUD_BOOKS_SUBDIR = 'Readest/Books'; export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'zip', 'cbz', 'pdf']; export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', '); +export const BOOK_UNGROUPED_NAME = ''; +export const BOOK_UNGROUPED_ID = ''; export const DEFAULT_SYSTEM_SETTINGS: Partial = { keepLogin: false, diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index edf3f5bd..0a67974a 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -230,3 +230,15 @@ foliate-view { .pull-indicator.flip svg { transform: rotate(180deg); } + +.scroll-container { + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); +} + +.action-bar-bottom { + position: fixed; + bottom: 0; + left: 0; + right: 0; +} diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 88ff4e9a..89e21f03 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -10,7 +10,9 @@ export interface Book { format: BookFormat; title: string; author: string; - group?: string; + group?: string; // deprecated in favor of groupId and groupName + groupId?: string; + groupName?: string; tags?: string[]; coverImageUrl?: string | null; @@ -25,6 +27,11 @@ export interface Book { progress?: [number, number]; // Add progress field: [current, total] } +export interface BookGroupType { + id: string; + name: string; +} + export interface PageInfo { current: number; next?: number; @@ -157,6 +164,7 @@ export interface BookDataRecord { } export interface BooksGroup { + id: string; name: string; books: Book[]; diff --git a/apps/readest-app/src/types/records.ts b/apps/readest-app/src/types/records.ts index 68c52efa..ddacc14b 100644 --- a/apps/readest-app/src/types/records.ts +++ b/apps/readest-app/src/types/records.ts @@ -4,7 +4,8 @@ export interface DBBook { format: string; title: string; author: string; - group?: string; + group_id?: string; + group_name?: string; tags?: string[]; progress?: [number, number]; diff --git a/apps/readest-app/src/utils/md5.ts b/apps/readest-app/src/utils/md5.ts index 6aaa0eda..6be2a117 100644 --- a/apps/readest-app/src/utils/md5.ts +++ b/apps/readest-app/src/utils/md5.ts @@ -1,5 +1,13 @@ import { md5 } from 'js-md5'; +export function isMd5(value: string): boolean { + return /^[0-9a-f]{32}$/.test(value); +} + +export function md5Fingerprint(value: string): string { + return md5(value).slice(0, 7); +} + export async function partialMD5(file: File): Promise { const step = 1024; const size = 1024; diff --git a/apps/readest-app/src/utils/nav.ts b/apps/readest-app/src/utils/nav.ts index 5ab1c277..4ad98764 100644 --- a/apps/readest-app/src/utils/nav.ts +++ b/apps/readest-app/src/utils/nav.ts @@ -25,8 +25,12 @@ export const navigateToLogin = (router: ReturnType) => { router.push(`/auth?redirect=${encodeURIComponent(currentPath)}`); }; -export const navigateToLibrary = (router: ReturnType) => { - router.push('/library'); +export const navigateToLibrary = ( + router: ReturnType, + queryParams?: string, + navOptions?: { scroll?: boolean }, +) => { + router.push(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions); }; export const redirectToLibrary = () => { diff --git a/apps/readest-app/src/utils/transform.ts b/apps/readest-app/src/utils/transform.ts index b2482953..d0e1f639 100644 --- a/apps/readest-app/src/utils/transform.ts +++ b/apps/readest-app/src/utils/transform.ts @@ -42,7 +42,8 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => { format, title, author, - group, + groupId, + groupName, tags, progress, createdAt, @@ -57,7 +58,8 @@ export const transformBookToDB = (book: unknown, userId: string): DBBook => { format, title, author, - group, + group_id: groupId, + group_name: groupName, tags: tags, progress: progress, created_at: new Date(createdAt ?? Date.now()).toISOString(), @@ -73,7 +75,8 @@ export const transformBookFromDB = (dbBook: DBBook): Book => { format, title, author, - group, + group_id, + group_name, tags, progress, created_at, @@ -87,7 +90,8 @@ export const transformBookFromDB = (dbBook: DBBook): Book => { format: format as BookFormat, title, author, - group, + groupId: group_id, + groupName: group_name, tags: tags, progress: progress, createdAt: new Date(created_at!).getTime(),