feat: organize the library with groups (#368)

This commit is contained in:
Huang Xin
2025-02-13 18:45:57 +01:00
committed by GitHub
parent 937832a8ee
commit 71b075dfa8
34 changed files with 931 additions and 272 deletions
@@ -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"
}
@@ -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": "Δημιουργία νέας ομάδας"
}
@@ -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"
}
@@ -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"
}
@@ -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": "नया समूह बनाएं"
}
@@ -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"
}
@@ -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"
}
@@ -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": "新しいグループを作成"
}
@@ -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": "새 그룹 만들기"
}
@@ -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ę"
}
@@ -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"
}
@@ -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": "Создать новую группу"
}
@@ -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"
}
@@ -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": "Створити нову групу"
}
@@ -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"
}
@@ -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": "创建新分组"
}
@@ -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": "創建新組"
}
@@ -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 {}
@@ -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<BookCoverProps> = ({ book, isPreview }) => {
return (
<div className='relative flex h-full w-full'>
<Image
src={book.coverImageUrl!}
alt={book.title}
fill={true}
className='object-cover'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div
className={clsx(
'invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
isPreview && 'bg-base-200/50',
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span
className={clsx(
isPreview ? 'line-clamp-2' : 'line-clamp-3',
isPreview ? 'text-[0.5em]' : 'text-lg',
)}
>
{formatTitle(book.title)}
</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span
className={clsx(
'text-neutral-content/50 line-clamp-1',
isPreview ? 'text-[0.4em]' : 'text-base',
)}
>
{formatAuthors(book.author)}
</span>
</div>
</div>
</div>
);
};
export default BookCover;
@@ -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<BookItemProps> = ({
book,
isSelectMode,
selectedBooks,
clickedBookHash,
transferProgress,
handleBookClick,
handleBookUpload,
handleBookDownload,
showBookDetailsModal,
@@ -38,52 +33,33 @@ const BookItem: React.FC<BookItemProps> = ({
const iconSize15 = useResponsiveSize(15);
const { appService } = useEnv();
const stopEvent = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
};
return (
<div
className={clsx('book-item', appService?.hasContextMenu ? 'cursor-pointer' : '')}
className={clsx(
'book-item flex h-full flex-col',
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
onContextMenu={bookContextMenuHandler.bind(null, book)}
>
<div className='bg-base-100 shadow-md' onClick={() => handleBookClick(book)}>
<div className='relative aspect-[28/41]'>
<Image
src={book.coverImageUrl!}
alt={book.title}
fill={true}
className='object-cover'
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div
className={clsx(
'invisible absolute inset-0 rounded-none p-2',
'text-neutral-content text-center font-serif font-medium',
<div className='bg-base-100 relative aspect-[28/41] shadow-md'>
<BookCover book={book} />
{selectedBooks.includes(book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(book.hash) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
>
<div className='flex h-1/2 items-center justify-center'>
<span className='line-clamp-3 text-lg'>{formatTitle(book.title)}</span>
</div>
<div className='h-1/6'></div>
<div className='flex h-1/3 items-center justify-center'>
<span className='text-neutral-content/50 line-clamp-1 text-base'>
{formatAuthors(book.author)}
</span>
</div>
</div>
{(selectedBooks.includes(book.hash) || clickedBookHash === book.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(book.hash) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
</div>
)}
</div>
)}
</div>
<div className={clsx('flex w-full flex-col p-0 pt-2')}>
<div className='min-w-0 flex-1'>
@@ -113,6 +89,10 @@ const BookItem: React.FC<BookItemProps> = ({
) : (
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onTouchStart={(e) => stopEvent(e)}
onMouseDown={(e) => stopEvent(e)}
onTouchEnd={(e) => stopEvent(e)}
onMouseUp={(e) => stopEvent(e)}
onClick={() => {
if (!book.uploadedAt) {
handleBookUpload(book);
@@ -129,6 +109,10 @@ const BookItem: React.FC<BookItemProps> = ({
)}
<button
className='show-detail-button opacity-0 group-hover:opacity-100'
onTouchStart={(e) => stopEvent(e)}
onMouseDown={(e) => stopEvent(e)}
onTouchEnd={(e) => stopEvent(e)}
onMouseUp={(e) => stopEvent(e)}
onClick={() => showBookDetailsModal(book)}
>
<CiCircleMore size={iconSize15} />
@@ -2,51 +2,19 @@ import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew } from 'react-icons/md';
import { MdDelete, MdOpenInNew, MdOutlineCreateNewFolder, MdOutlineCancel } from 'react-icons/md';
import { PiPlus } from 'react-icons/pi';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { Book, BooksGroup } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToReader } from '@/utils/nav';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import BookItem from './BookItem';
import GroupItem from './GroupItem';
type BookshelfItem = Book | BooksGroup;
const UNGROUPED_NAME = 'ungrouped';
const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
book.group = book.group || UNGROUPED_NAME;
const groupIndex = acc.findIndex((group) => group.name === book.group);
const booksGroup = acc[acc.findIndex((group) => group.name === book.group)];
if (booksGroup) {
booksGroup.books.push(book);
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
} else {
acc.push({
name: book.group,
books: [book],
updatedAt: book.updatedAt,
});
}
return acc;
}, []);
const ungroupedBooks: Book[] = groups.find((group) => group.name === UNGROUPED_NAME)?.books || [];
const groupedBooks: BooksGroup[] = groups.filter((group) => group.name !== UNGROUPED_NAME);
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import { isMd5 } from '@/utils/md5';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
libraryBooks: Book[];
@@ -55,7 +23,9 @@ interface BookshelfProps {
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book) => void;
handleBookDelete: (book: Book) => void;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
handleToggleSelectMode: () => void;
booksTransferProgress: { [key: string]: number | null };
}
@@ -66,43 +36,25 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookUpload,
handleBookDownload,
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
handleToggleSelectMode,
booksTransferProgress,
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
const { appService } = useEnv();
const [loading, setLoading] = useState(false);
const [selectedBooks, setSelectedBooks] = useState<string[]>([]);
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [clickedImage, setClickedImage] = useState<string | null>(null);
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const isImportingBook = useRef(false);
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 showBookDetailsModal = async (book: Book) => {
if (await makeBookAvailable(book)) {
handleShowDetailsBook(book);
}
};
const { setLibrary } = useLibraryStore();
const allBookshelfItems = generateBookshelfItems(libraryBooks);
useEffect(() => {
setSelectedBooks([]);
@@ -127,24 +79,27 @@ const Bookshelf: React.FC<BookshelfProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [importBookUrl, appService]);
const bookshelfItems = generateBookshelfItems(libraryBooks);
const handleBookClick = async (book: Book) => {
if (!(await makeBookAvailable(book))) return;
if (isSelectMode) {
toggleSelection(book.hash);
useEffect(() => {
const group = searchParams?.get('group') || '';
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
) as BooksGroup;
if (booksGroup) {
setNavBooksGroup(booksGroup);
} else {
navigateToLibrary(router);
}
} else {
setClickedImage(book.hash);
setTimeout(() => setClickedImage(null), 300);
setTimeout(() => setLoading(true), 200);
navigateToReader(router, [book.hash]);
setNavBooksGroup(null);
navigateToLibrary(router);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, showGroupingModal]);
const toggleSelection = (id: string) => {
setSelectedBooks((prev) =>
prev.includes(id) ? prev.filter((bookId) => bookId !== id) : [...prev, id],
prev.includes(id) ? prev.filter((selectedId) => selectedId !== id) : [...prev, id],
);
};
@@ -154,8 +109,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const confirmDelete = async () => {
for (const selectedBook of selectedBooks) {
const book = libraryBooks.find((b) => b.hash === selectedBook);
for (const id of selectedBooks) {
const book = libraryBooks.find((b) => b.hash === id || b.groupId === id);
if (book) {
await handleBookDelete(book);
}
@@ -168,82 +123,34 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowDeleteAlert(true);
};
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();
const groupSelectedBooks = () => {
setShowGroupingModal(true);
};
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
return (
<div className='bookshelf'>
<div className='grid grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
{bookshelfItems.map((item, index) => (
<div
<div className='grid flex-1 grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
{currentBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
clickedBookHash={clickedImage}
handleBookClick={handleBookClick}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
bookContextMenuHandler={bookContextMenuHandler}
transferProgress={booksTransferProgress[item.hash] || null}
/>
) : (
<GroupItem group={item} />
)}
</div>
</div>
item={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
setLoading={setLoading}
toggleSelection={toggleSelection}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
transferProgress={
'hash' in item ? booksTransferProgress[(item as Book).hash] || null : null
}
/>
))}
{bookshelfItems.length > 0 && (
{!navBooksGroup && allBookshelfItems.length > 0 && (
<div
className='border-1 bg-base-100 hover:bg-base-300/50 m-4 flex aspect-[28/41] items-center justify-center'
role='button'
@@ -258,34 +165,72 @@ const Bookshelf: React.FC<BookshelfProps> = ({
<Spinner loading />
</div>
)}
<div
className={clsx(
'action-bar sticky bottom-0 left-0 right-0 z-40',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
{selectedBooks.length > 0 && (
<div className={clsx('action-bar-bottom z-[99] pb-[calc(env(safe-area-inset-bottom)+16px)]')}>
{isSelectMode && (
<div
className={clsx(
'text-base-content bg-base-300 mx-auto flex w-fit',
'space-x-4 rounded-lg p-4 shadow-lg',
'text-base-content bg-base-300 mx-auto flex w-fit items-center justify-center',
'space-x-6 rounded-lg p-4 shadow-lg',
)}
>
<button onClick={openSelectedBooks} className='flex items-center space-x-2'>
<button
onClick={openSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
(!selectedBooks.length || !selectedBooks.every((id) => isMd5(id))) &&
'btn-disabled opacity-50',
)}
>
<MdOpenInNew />
<span>{_('Open')}</span>
<div>{_('Open')}</div>
</button>
<button onClick={deleteSelectedBooks} className='flex items-center space-x-2'>
<button
onClick={groupSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<MdOutlineCreateNewFolder />
<div>{_('Add to Group')}</div>
</button>
<button
onClick={deleteSelectedBooks}
className={clsx(
'flex flex-col items-center justify-center',
!selectedBooks.length && 'btn-disabled opacity-50',
)}
>
<MdDelete className='fill-red-500' />
<span className='text-red-500'>{_('Delete')}</span>
<div className='text-red-500'>{_('Delete')}</div>
</button>
<button
onClick={() => handleToggleSelectMode()}
className={clsx('flex flex-col items-center justify-center')}
>
<MdOutlineCancel />
<div>{_('Cancel')}</div>
</button>
</div>
)}
</div>
{showGroupingModal && (
<div>
<GroupingModal
libraryBooks={libraryBooks}
selectedBooks={selectedBooks}
onConfirm={() => {
setSelectedBooks([]);
setShowGroupingModal(false);
}}
onCancel={() => setShowGroupingModal(false)}
/>
</div>
)}
{showDeleteAlert && (
<div
className={clsx(
'sticky bottom-0 left-0 right-0 z-50 flex justify-center',
'action-bar-bottom z-[100] flex justify-center',
'pb-[calc(env(safe-area-inset-bottom)+16px)]',
)}
>
@@ -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<React.SetStateAction<boolean>>;
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<BookshelfItemProps> = ({
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<ReturnType<typeof setTimeout> | null>(null);
const longPressStartRef = useRef<number | null>(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 (
<div
className='hover:bg-base-300/50 group flex h-full flex-col p-4'
onTouchStart={(event) => handleItemTouchStart(item, event)}
onTouchEnd={(event) => handleItemTouchEnd(item, event)}
onMouseDown={(event) => handleItemTouchStart(item, event)}
onMouseUp={(event) => handleItemTouchEnd(item, event)}
onContextMenu={(event) => handleItemContextMenu(item, event)}
>
<div className='flex-grow'>
{'format' in item ? (
<BookItem
book={item}
isSelectMode={isSelectMode}
selectedBooks={selectedBooks}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
showBookDetailsModal={showBookDetailsModal}
bookContextMenuHandler={bookContextMenuHandler}
transferProgress={transferProgress}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} selectedBooks={selectedBooks} />
)}
</div>
</div>
);
};
export default BookshelfItem;
@@ -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<GroupItemProps> = ({ group }) => {
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, selectedBooks }) => {
return (
<div>
{group.books.map((book) => (
<div key={book.hash} className='card bg-base-100 w-full shadow-md'>
<figure>
<Image
width={10}
height={10}
src={book.coverImageUrl!}
alt={book.title || ''}
className='h-48 w-full object-cover'
/>
</figure>
<div className='card-body p-4'>
<h3 className='card-title line-clamp-2 text-sm'>{formatTitle(book.title)}</h3>
<p className='text-neutral-content line-clamp-1 text-xs'>
{formatAuthors(book.author)}
</p>
<ReadingProgress book={book} />
</div>
<div className='group-item flex h-full flex-col'>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center p-2 shadow-md'>
<div className='grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'>
{group.books.slice(0, 4).map((book) => (
<div key={book.hash} className='relative aspect-[28/41] h-full w-full'>
<BookCover book={book} isPreview />
</div>
))}
</div>
))}
<h2 className='mb-2 text-lg font-bold'>{group.name}</h2>
{selectedBooks.includes(group.id) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(group.id) ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
</div>
)}
</div>
<div className='min-w-0 flex-1 pt-2'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold'>
{group.name}
</h4>
</div>
</div>
);
};
@@ -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<GroupingModalProps> = ({
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<BookGroupType | null>(null);
const [newGroups, setNewGroups] = useState<BookGroupType[]>([]);
const [allGroups, setAllGroups] = useState<BookGroupType[]>(groupsList);
const editorRef = useRef<HTMLInputElement>(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 (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'>
<div className='modal-box bg-base-100 max-h-[85%] w-[50%] min-w-64 max-w-[440px] overflow-y-auto rounded-2xl p-6 shadow-xl'>
<h2 className='text-center text-lg font-bold'>{_('Group Books')}</h2>
<div className='mt-4 flex w-full flex-wrap justify-between gap-2 text-base'>
{isSelectedBooksHasGroup && (
<button
onClick={handleRemoveFromGroup}
className='btn btn-ghost flex items-center p-2 text-blue-500'
>
<HiOutlineFolderRemove size={iconSize} />
<span>{_('Remove From Group')}</span>
</button>
)}
<button
onClick={handleCreateGroup}
className='btn btn-ghost flex items-center p-2 text-blue-500'
>
<HiOutlineFolderAdd size={iconSize} />
<span>{_('Create New Group')}</span>
</button>
</div>
{showInput && (
<div className='mt-4 flex items-center gap-2'>
<input
type='text'
autoFocus
ref={editorRef}
value={editGroupName}
onChange={(e) => 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'
/>
<button
className={clsx(
'btn btn-ghost settings-content hover:bg-transparent',
'flex h-[1.3em] min-h-[1.3em] items-end p-0',
editorRef.current && editorRef.current.value ? '' : 'btn-disabled !bg-opacity-0',
)}
onClick={() => handleConfirmCreateGroup()}
>
<div className='pr-1 align-bottom text-xs text-blue-400'>{_('Save')}</div>
</button>
</div>
)}
<ul className='groups-list mt-4 grid grid-cols-2 gap-2'>
{allGroups.map((group, index) => (
<button
key={index}
className={clsx(
'hover:bg-base-300 text-base-content flex w-full',
'items-center justify-between rounded-md px-2 py-2',
)}
onClick={() => handleToggleSelectGroup(group)}
>
<div className='flex min-w-0 items-center'>
<span style={{ minWidth: `${iconSize}px` }}>
<HiOutlineFolder size={iconSize} />
</span>
<span
className={clsx('mx-2 flex-1 truncate text-base sm:text-sm')}
style={{ minWidth: 0 }}
>
{group.name}
</span>
</div>
<span className='text-neutral-content hidden shrink-0 text-sm sm:flex'>
{selectedGroup && selectedGroup.id == group.id && (
<MdCheck className='fill-blue-500' size={iconSize} />
)}
</span>
</button>
))}
</ul>
<div className='mt-6 flex justify-end gap-x-4 p-2'>
<button
onClick={handleConfirmGrouping}
className={clsx(
'flex items-center text-blue-500',
!selectedGroup && 'btn-disabled opacity-50',
)}
>
{_('Confirm')}
</button>
<button onClick={onCancel} className='flex items-center'>
{_('Cancel')}
</button>
</div>
</div>
</div>
);
};
export default GroupingModal;
@@ -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<LibraryHeaderProps> = ({
onToggleSelectMode,
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
const iconSize22 = useResponsiveSize(22);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -44,6 +50,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
}, [onToggleSelectMode]);
const windowButtonVisible = appService?.appPlatform !== 'web' && !isTrafficLightVisible;
const isInGroupView = !!searchParams?.get('group');
return (
<div
@@ -56,19 +63,33 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
>
<div className='flex items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
<span className='absolute left-8 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
placeholder={_('Search Books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
/>
{isInGroupView && (
<button
onClick={() => {
navigateToLibrary(router);
}}
className='btn btn-ghost mr-4 h-7 min-h-7 w-7 p-0'
>
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Go Back')}>
<MdArrowBackIosNew size={iconSize22} />
</div>
</button>
)}
<div className='relative flex h-7 w-full items-center'>
<span className='absolute left-3 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
placeholder={_('Search Books...')}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-7 w-full pl-10 pr-10',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
<span className='mx-2 h-6 w-[1px] bg-gray-400'></span>
<Dropdown
+7 -1
View File
@@ -337,6 +337,10 @@ const LibraryPage = () => {
setIsSelectMode(!isSelectMode);
};
const handleSetSelectMode = (selectMode: boolean) => {
setIsSelectMode(selectMode);
};
const handleShowDetailsBook = (book: Book) => {
setShowDetailsBook(book);
};
@@ -380,7 +384,7 @@ const LibraryPage = () => {
<div
ref={containerRef}
className={clsx(
'mt-12 flex-grow overflow-auto px-2',
'scroll-container mt-12 flex-grow overflow-auto px-2',
appService?.hasSafeAreaInset && 'mt-[calc(48px+env(safe-area-inset-top))]',
)}
>
@@ -391,7 +395,9 @@ const LibraryPage = () => {
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleToggleSelectMode={handleToggleSelectMode}
booksTransferProgress={booksTransferProgress}
/>
</div>
+5 -1
View File
@@ -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 (
<div
className='absolute left-1/2 top-4 -translate-x-1/2 transform pt-16 text-center'
className={clsx(
'absolute left-1/2 -translate-x-1/2 transform text-center',
'top-4 pt-[calc(env(safe-area-inset-top)+64px)]',
)}
role='status'
>
<span className='loading loading-dots loading-lg'></span>
@@ -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<SystemSettings> = {
keepLogin: false,
+12
View File
@@ -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;
}
+9 -1
View File
@@ -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[];
+2 -1
View File
@@ -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];
+8
View File
@@ -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<string> {
const step = 1024;
const size = 1024;
+6 -2
View File
@@ -25,8 +25,12 @@ export const navigateToLogin = (router: ReturnType<typeof useRouter>) => {
router.push(`/auth?redirect=${encodeURIComponent(currentPath)}`);
};
export const navigateToLibrary = (router: ReturnType<typeof useRouter>) => {
router.push('/library');
export const navigateToLibrary = (
router: ReturnType<typeof useRouter>,
queryParams?: string,
navOptions?: { scroll?: boolean },
) => {
router.push(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions);
};
export const redirectToLibrary = () => {
+8 -4
View File
@@ -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(),