forked from akai/readest
This commit is contained in:
@@ -671,5 +671,6 @@
|
||||
"Updating email ...": "جارٍ تحديث البريد الإلكتروني ...",
|
||||
"Update email": "تحديث البريد الإلكتروني",
|
||||
"Current email": "البريد الإلكتروني الحالي",
|
||||
"Update Email": "تحديث البريد الإلكتروني"
|
||||
"Update Email": "تحديث البريد الإلكتروني",
|
||||
"All": "الكل"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "ইমেল আপডেট হচ্ছে ...",
|
||||
"Update email": "ইমেল আপডেট করুন",
|
||||
"Current email": "বর্তমান ইমেল",
|
||||
"Update Email": "ইমেল আপডেট করুন"
|
||||
"Update Email": "ইমেল আপডেট করুন",
|
||||
"All": "সব"
|
||||
}
|
||||
|
||||
@@ -651,5 +651,6 @@
|
||||
"Updating email ...": "ཡིག་འཕྲིན་གསར་བསྒྱུར་བཞིན་པ། ...",
|
||||
"Update email": "ཡིག་འཕྲིན་གསར་བསྒྱུར།",
|
||||
"Current email": "མིག་སྔའི་ཡིག་འཕྲིན།",
|
||||
"Update Email": "ཡིག་འཕྲིན་གསར་བསྒྱུར།"
|
||||
"Update Email": "ཡིག་འཕྲིན་གསར་བསྒྱུར།",
|
||||
"All": "ཆེན་མོ།"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "E-Mail wird aktualisiert ...",
|
||||
"Update email": "E-Mail aktualisieren",
|
||||
"Current email": "Aktuelle E-Mail",
|
||||
"Update Email": "E-Mail aktualisieren"
|
||||
"Update Email": "E-Mail aktualisieren",
|
||||
"All": "Alle"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "Ενημέρωση email ...",
|
||||
"Update email": "Ενημέρωση email",
|
||||
"Current email": "Τρέχον email",
|
||||
"Update Email": "Ενημέρωση email"
|
||||
"Update Email": "Ενημέρωση email",
|
||||
"All": "Όλα"
|
||||
}
|
||||
|
||||
@@ -659,5 +659,6 @@
|
||||
"Updating email ...": "Actualizando correo electrónico ...",
|
||||
"Update email": "Actualizar correo electrónico",
|
||||
"Current email": "Correo electrónico actual",
|
||||
"Update Email": "Actualizar correo electrónico"
|
||||
"Update Email": "Actualizar correo electrónico",
|
||||
"All": "Todos"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "در حال بهروزرسانی ایمیل ...",
|
||||
"Update email": "بهروزرسانی ایمیل",
|
||||
"Current email": "ایمیل فعلی",
|
||||
"Update Email": "بهروزرسانی ایمیل"
|
||||
"Update Email": "بهروزرسانی ایمیل",
|
||||
"All": "همه"
|
||||
}
|
||||
|
||||
@@ -659,5 +659,6 @@
|
||||
"Updating email ...": "Mise à jour de l’e-mail ...",
|
||||
"Update email": "Mettre à jour l’e-mail",
|
||||
"Current email": "E-mail actuel",
|
||||
"Update Email": "Mettre à jour l’e-mail"
|
||||
"Update Email": "Mettre à jour l’e-mail",
|
||||
"All": "Tous"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "ईमेल अपडेट किया जा रहा है ...",
|
||||
"Update email": "ईमेल अपडेट करें",
|
||||
"Current email": "वर्तमान ईमेल",
|
||||
"Update Email": "ईमेल अपडेट करें"
|
||||
"Update Email": "ईमेल अपडेट करें",
|
||||
"All": "सभी"
|
||||
}
|
||||
|
||||
@@ -651,5 +651,6 @@
|
||||
"Updating email ...": "Memperbarui email ...",
|
||||
"Update email": "Perbarui email",
|
||||
"Current email": "Email saat ini",
|
||||
"Update Email": "Perbarui email"
|
||||
"Update Email": "Perbarui email",
|
||||
"All": "Semua"
|
||||
}
|
||||
|
||||
@@ -659,5 +659,6 @@
|
||||
"Updating email ...": "Aggiornamento dell'email ...",
|
||||
"Update email": "Aggiorna email",
|
||||
"Current email": "Email attuale",
|
||||
"Update Email": "Aggiorna email"
|
||||
"Update Email": "Aggiorna email",
|
||||
"All": "Tutti"
|
||||
}
|
||||
|
||||
@@ -651,5 +651,6 @@
|
||||
"Updating email ...": "メールを更新しています ...",
|
||||
"Update email": "メールを更新",
|
||||
"Current email": "現在のメールアドレス",
|
||||
"Update Email": "メールを更新"
|
||||
"Update Email": "メールを更新",
|
||||
"All": "すべて"
|
||||
}
|
||||
|
||||
@@ -651,5 +651,6 @@
|
||||
"Updating email ...": "이메일 업데이트 중...",
|
||||
"Update email": "이메일 업데이트",
|
||||
"Current email": "현재 이메일",
|
||||
"Update Email": "이메일 업데이트"
|
||||
"Update Email": "이메일 업데이트",
|
||||
"All": "전체"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "E-mail wordt bijgewerkt...",
|
||||
"Update email": "E-mail bijwerken",
|
||||
"Current email": "Huidig e-mailadres",
|
||||
"Update Email": "E-mail bijwerken"
|
||||
"Update Email": "E-mail bijwerken",
|
||||
"All": "Alles"
|
||||
}
|
||||
|
||||
@@ -663,5 +663,6 @@
|
||||
"Updating email ...": "Aktualizowanie e-maila...",
|
||||
"Update email": "Zaktualizuj e-mail",
|
||||
"Current email": "Obecny e-mail",
|
||||
"Update Email": "Zaktualizuj e-mail"
|
||||
"Update Email": "Zaktualizuj e-mail",
|
||||
"All": "Wszystkie"
|
||||
}
|
||||
|
||||
@@ -659,5 +659,6 @@
|
||||
"Updating email ...": "A atualizar o e-mail...",
|
||||
"Update email": "Atualizar e-mail",
|
||||
"Current email": "E-mail atual",
|
||||
"Update Email": "Atualizar e-mail"
|
||||
"Update Email": "Atualizar e-mail",
|
||||
"All": "Todos"
|
||||
}
|
||||
|
||||
@@ -663,5 +663,6 @@
|
||||
"Updating email ...": "Обновление электронной почты ...",
|
||||
"Update email": "Обновить электронную почту",
|
||||
"Current email": "Текущий адрес электронной почты",
|
||||
"Update Email": "Обновить электронную почту"
|
||||
"Update Email": "Обновить электронную почту",
|
||||
"All": "Все"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "විද්යුත් තැපැල් යාවත්කාලීන කරමින් ...",
|
||||
"Update email": "විද්යුත් තැපැල් යාවත්කාලීන කරන්න",
|
||||
"Current email": "වත්මන් විද්යුත් තැපැල්",
|
||||
"Update Email": "විද්යුත් තැපැල් යාවත්කාලීන කරන්න"
|
||||
"Update Email": "විද්යුත් තැපැල් යාවත්කාලීන කරන්න",
|
||||
"All": "සියල්ල"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "Uppdaterar e-post ...",
|
||||
"Update email": "Uppdatera e-post",
|
||||
"Current email": "Nuvarande e-post",
|
||||
"Update Email": "Uppdatera e-post"
|
||||
"Update Email": "Uppdatera e-post",
|
||||
"All": "Alla"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "மின்னஞ்சல் புதுப்பிக்கப்படுகிறது ...",
|
||||
"Update email": "மின்னஞ்சல் புதுப்பிக்கவும்",
|
||||
"Current email": "தற்போதைய மின்னஞ்சல்",
|
||||
"Update Email": "மின்னஞ்சல் புதுப்பிக்கவும்"
|
||||
"Update Email": "மின்னஞ்சல் புதுப்பிக்கவும்",
|
||||
"All": "அனைத்து"
|
||||
}
|
||||
|
||||
@@ -651,5 +651,6 @@
|
||||
"Updating email ...": "กำลังอัปเดตอีเมล ...",
|
||||
"Update email": "อัปเดตอีเมล",
|
||||
"Current email": "อีเมลปัจจุบัน",
|
||||
"Update Email": "อัปเดตอีเมล"
|
||||
"Update Email": "อัปเดตอีเมล",
|
||||
"All": "ทั้งหมด"
|
||||
}
|
||||
|
||||
@@ -655,5 +655,6 @@
|
||||
"Updating email ...": "E-posta güncelleniyor ...",
|
||||
"Update email": "E-postayı güncelle",
|
||||
"Current email": "Mevcut e-posta",
|
||||
"Update Email": "E-postayı güncelle"
|
||||
"Update Email": "E-postayı güncelle",
|
||||
"All": "Tümü"
|
||||
}
|
||||
|
||||
@@ -663,5 +663,6 @@
|
||||
"Updating email ...": "Оновлення електронної пошти ...",
|
||||
"Update email": "Оновити електронну пошту",
|
||||
"Current email": "Поточна електронна пошта",
|
||||
"Update Email": "Оновити електронну пошту"
|
||||
"Update Email": "Оновити електронну пошту",
|
||||
"All": "Всі"
|
||||
}
|
||||
|
||||
@@ -651,5 +651,6 @@
|
||||
"Updating email ...": "Đang cập nhật email ...",
|
||||
"Update email": "Cập nhật email",
|
||||
"Current email": "Email hiện tại",
|
||||
"Update Email": "Cập nhật email"
|
||||
"Update Email": "Cập nhật email",
|
||||
"All": "Tất cả"
|
||||
}
|
||||
|
||||
@@ -651,5 +651,6 @@
|
||||
"Updating email ...": "正在更新邮箱 ...",
|
||||
"Update email": "更新邮箱",
|
||||
"Current email": "当前邮箱",
|
||||
"Update Email": "更新邮箱"
|
||||
"Update Email": "更新邮箱",
|
||||
"All": "全部"
|
||||
}
|
||||
|
||||
@@ -651,5 +651,6 @@
|
||||
"Updating email ...": "正在更新電子郵件 ...",
|
||||
"Update email": "更新電子郵件",
|
||||
"Current email": "當前電子郵件",
|
||||
"Update Email": "更新電子郵件"
|
||||
"Update Email": "更新電子郵件",
|
||||
"All": "全部"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { Book } from '@/types/book';
|
||||
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
@@ -66,8 +66,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
|
||||
const [showGroupingModal, setShowGroupingModal] = useState(false);
|
||||
const [queryTerm, setQueryTerm] = useState<string | null>(null);
|
||||
const [navBooksGroup, setNavBooksGroup] = useState<BooksGroup | null>(null);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const [groupId, setGroupId] = useState(searchParams?.get('group') || '');
|
||||
const [viewMode, setViewMode] = useState(searchParams?.get('view') || settings.libraryViewMode);
|
||||
const [sortBy, setSortBy] = useState(searchParams?.get('sort') || settings.librarySortBy);
|
||||
const [sortOrder, setSortOrder] = useState(
|
||||
@@ -78,6 +78,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
|
||||
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
|
||||
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
|
||||
const { getGroupName } = useLibraryStore();
|
||||
|
||||
const bookFilter = useMemo(() => createBookFilter(queryTerm), [queryTerm]);
|
||||
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
|
||||
@@ -87,9 +88,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks;
|
||||
}, [libraryBooks, queryTerm, bookFilter]);
|
||||
|
||||
const allBookshelfItems = useMemo(() => {
|
||||
return generateBookshelfItems(filteredBooks);
|
||||
}, [filteredBooks]);
|
||||
const currentBookshelfItems = useMemo(() => {
|
||||
const groupName = getGroupName(groupId) || '';
|
||||
if (groupId && !groupName) {
|
||||
return [];
|
||||
}
|
||||
const items = generateBookshelfItems(filteredBooks, groupName);
|
||||
return items;
|
||||
}, [filteredBooks, groupId, getGroupName]);
|
||||
|
||||
const autofocusRef = useAutoFocus<HTMLDivElement>();
|
||||
|
||||
@@ -113,12 +119,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}, [importBookUrl, appService]);
|
||||
|
||||
useEffect(() => {
|
||||
if (navBooksGroup) {
|
||||
setCurrentBookshelf(navBooksGroup.books);
|
||||
} else {
|
||||
setCurrentBookshelf(allBookshelfItems);
|
||||
}
|
||||
}, [allBookshelfItems, navBooksGroup, setCurrentBookshelf]);
|
||||
setCurrentBookshelf(currentBookshelfItems);
|
||||
}, [currentBookshelfItems, setCurrentBookshelf]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
@@ -162,23 +164,19 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
params.delete('order');
|
||||
params.delete('view');
|
||||
}
|
||||
setGroupId(group);
|
||||
if (group) {
|
||||
const booksGroup = allBookshelfItems.find(
|
||||
(item) => 'name' in item && item.id === group,
|
||||
) as BooksGroup;
|
||||
if (booksGroup) {
|
||||
setNavBooksGroup(booksGroup);
|
||||
if (currentBookshelfItems.length > 0) {
|
||||
params.set('group', group);
|
||||
} else {
|
||||
params.delete('group');
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}
|
||||
} else {
|
||||
setNavBooksGroup(null);
|
||||
params.delete('group');
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
}
|
||||
}, [router, settings, searchParams, allBookshelfItems, showGroupingModal]);
|
||||
}, [router, settings, searchParams, currentBookshelfItems, showGroupingModal]);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(id: string) => {
|
||||
@@ -252,7 +250,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
|
||||
const sortedBookshelfItems = useMemo(() => {
|
||||
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
|
||||
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
|
||||
return currentBookshelfItems.sort((a, b) => {
|
||||
if (sortBy === 'updated') {
|
||||
return (
|
||||
@@ -268,7 +265,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [sortOrder, sortBy, uiLanguage, navBooksGroup, allBookshelfItems, bookSorter]);
|
||||
}, [sortOrder, sortBy, uiLanguage, currentBookshelfItems, bookSorter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelectMode) {
|
||||
@@ -330,7 +327,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{viewMode === 'grid' && !navBooksGroup && allBookshelfItems.length > 0 && (
|
||||
{viewMode === 'grid' && currentBookshelfItems.length > 0 && (
|
||||
<div className={clsx('mx-0 my-4 sm:mx-4')}>
|
||||
<button
|
||||
aria-label={_('Import Books')}
|
||||
@@ -424,6 +421,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<GroupingModal
|
||||
libraryBooks={libraryBooks}
|
||||
selectedBooks={selectedBooks}
|
||||
parentGroupName={getGroupName(groupId) || ''}
|
||||
onCancel={() => {
|
||||
setShowGroupingModal(false);
|
||||
setShowSelectModeActions(true);
|
||||
|
||||
@@ -16,25 +16,46 @@ import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
import { Book, BooksGroup } from '@/types/book';
|
||||
import { md5Fingerprint } from '@/utils/md5';
|
||||
import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
|
||||
export type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => {
|
||||
export const generateBookshelfItems = (
|
||||
books: Book[],
|
||||
parentGroupName: string,
|
||||
): (Book | BooksGroup)[] => {
|
||||
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
|
||||
if (book.deletedAt) return acc;
|
||||
if (parentGroupName && (!book.groupName || !book.groupName.startsWith(parentGroupName)))
|
||||
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)];
|
||||
const slashIndex = book.groupName.indexOf('/', parentGroupName.length + 1);
|
||||
const leafGroupName = book.groupName.substring(
|
||||
parentGroupName ? parentGroupName.length + 1 : 0,
|
||||
slashIndex > 0 ? slashIndex : undefined,
|
||||
);
|
||||
const fullGroupName = parentGroupName
|
||||
? `${parentGroupName}${leafGroupName ? `/${leafGroupName}` : ''}`
|
||||
: leafGroupName;
|
||||
const groupIndex = acc.findIndex(
|
||||
(group) =>
|
||||
group.name === fullGroupName ||
|
||||
(parentGroupName && group.name === parentGroupName) ||
|
||||
(leafGroupName === BOOK_UNGROUPED_NAME && group.name === BOOK_UNGROUPED_NAME),
|
||||
);
|
||||
const booksGroup = acc[groupIndex];
|
||||
if (booksGroup) {
|
||||
booksGroup.books.push(book);
|
||||
booksGroup.updatedAt = Math.max(acc[groupIndex]!.updatedAt, book.updatedAt);
|
||||
booksGroup.updatedAt = Math.max(booksGroup.updatedAt, book.updatedAt);
|
||||
} else {
|
||||
let groupName = fullGroupName;
|
||||
acc.push({
|
||||
id: book.groupId,
|
||||
name: book.groupName,
|
||||
id: groupName === parentGroupName ? BOOK_UNGROUPED_ID : md5Fingerprint(groupName),
|
||||
name: groupName === parentGroupName ? BOOK_UNGROUPED_NAME : groupName,
|
||||
displayName: leafGroupName,
|
||||
books: [book],
|
||||
updatedAt: book.updatedAt,
|
||||
});
|
||||
@@ -45,8 +66,11 @@ export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] =>
|
||||
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);
|
||||
groups.find((group) => group.name === BOOK_UNGROUPED_NAME || group.name === parentGroupName)
|
||||
?.books || [];
|
||||
const groupedBooks: BooksGroup[] = groups.filter(
|
||||
(group) => group.name !== BOOK_UNGROUPED_NAME && group.name !== parentGroupName,
|
||||
);
|
||||
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
};
|
||||
|
||||
@@ -310,7 +334,8 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
<div
|
||||
className={clsx(
|
||||
'visible-focus-inset-2 group',
|
||||
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
|
||||
mode === 'grid' &&
|
||||
'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-2 sm:px-4 sm:py-4',
|
||||
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
|
||||
appService?.isMobileApp && 'no-context-menu',
|
||||
pressing && mode === 'grid' ? 'scale-95' : 'scale-100',
|
||||
|
||||
@@ -187,7 +187,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
</div>
|
||||
{mode === 'list' && (
|
||||
<div className='text-base-content/75 w-28 min-w-24 max-w-40 overflow-hidden text-ellipsis text-base font-semibold'>
|
||||
{group.name}
|
||||
{group.displayName}
|
||||
</div>
|
||||
)}
|
||||
{groupSelected && (
|
||||
@@ -207,7 +207,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
<div className={clsx('flex w-full flex-col pt-2')}>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-xs font-semibold'>
|
||||
{group.name}
|
||||
{group.displayName}
|
||||
</h4>
|
||||
</div>
|
||||
<div className='placeholder' style={{ height: `${iconSize15}px` }}></div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
import { MdCheck, MdChevronRight } from 'react-icons/md';
|
||||
import { HiOutlineFolder, HiOutlineFolderAdd, HiOutlineFolderRemove } from 'react-icons/hi';
|
||||
import { IoMdArrowBack } from 'react-icons/io';
|
||||
|
||||
import { Book, BookGroupType } from '@/types/book';
|
||||
import { isMd5 } from '@/utils/md5';
|
||||
@@ -10,10 +11,12 @@ 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 { getBreadcrumbs } from '../utils/libraryUtils';
|
||||
|
||||
interface GroupingModalProps {
|
||||
libraryBooks: Book[];
|
||||
selectedBooks: string[];
|
||||
parentGroupName: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
@@ -21,22 +24,33 @@ interface GroupingModalProps {
|
||||
const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
libraryBooks,
|
||||
selectedBooks,
|
||||
parentGroupName,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const { appService } = useEnv();
|
||||
const { setLibrary, addGroup, getGroups, refreshGroups } = useLibraryStore();
|
||||
|
||||
const allGroups = getGroups();
|
||||
const { setLibrary, addGroup, getGroups, getGroupsByParent, getParentPath, refreshGroups } =
|
||||
useLibraryStore();
|
||||
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [editGroupName, setEditGroupName] = useState(_('Untitled Group'));
|
||||
const [editGroupName, setEditGroupName] = useState('');
|
||||
const [selectedGroup, setSelectedGroup] = useState<BookGroupType | null>(null);
|
||||
const [newGroup, setNewGroup] = useState<BookGroupType | null>(null);
|
||||
|
||||
const editorRef = useRef<HTMLInputElement>(null);
|
||||
const iconSize = useResponsiveSize(16);
|
||||
|
||||
const allGroups = getGroups();
|
||||
const currentGroups = getGroupsByParent(currentPath);
|
||||
const currentGroupsList =
|
||||
newGroup &&
|
||||
!currentGroups.some((g) => g.id === newGroup.id) &&
|
||||
!currentGroups.some((g) => newGroup.name.startsWith(g.name))
|
||||
? [newGroup, ...currentGroups]
|
||||
: currentGroups;
|
||||
|
||||
const isSelectedBooksHasGroup =
|
||||
selectedBooks.some((hash) => !isMd5(hash)) ||
|
||||
selectedBooks
|
||||
@@ -44,20 +58,31 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
.some((group) => group && group !== BOOK_UNGROUPED_NAME);
|
||||
|
||||
const generateNextUntitledGroupName = () => {
|
||||
const untitledGroupPattern = new RegExp(`^${_('Untitled Group')}\\s*(\\d+)?$`);
|
||||
const baseName = _('Untitled Group');
|
||||
const basePattern = parentGroupName
|
||||
? `${parentGroupName}/${baseName}`
|
||||
: currentPath
|
||||
? `${currentPath}/${baseName}`
|
||||
: baseName;
|
||||
|
||||
const escapedPattern = basePattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const untitledGroupPattern = new RegExp(`^${escapedPattern}\\s*(\\d+)?$`);
|
||||
|
||||
const untitledGroupNumbers = allGroups
|
||||
.map((group) => {
|
||||
const match = group.name.match(untitledGroupPattern);
|
||||
return match ? parseInt(match[1] || '0', 10) : null;
|
||||
return match ? parseInt(match[1] || '1', 10) : null;
|
||||
})
|
||||
.filter((num) => num !== null);
|
||||
.filter((num) => num !== null) as number[];
|
||||
|
||||
const nextNumber = untitledGroupNumbers.length > 0 ? Math.max(...untitledGroupNumbers) + 1 : 1;
|
||||
|
||||
return `${_('Untitled Group')} ${nextNumber}`;
|
||||
return `${basePattern} ${nextNumber}`;
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
const nextName = generateNextUntitledGroupName();
|
||||
setEditGroupName(nextName);
|
||||
setShowInput(true);
|
||||
};
|
||||
|
||||
@@ -83,14 +108,20 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
};
|
||||
|
||||
const handleConfirmCreateGroup = () => {
|
||||
const groupName = editGroupName.trim();
|
||||
let groupName = editGroupName.trim();
|
||||
if (groupName) {
|
||||
const newGroup = addGroup(groupName);
|
||||
setSelectedGroup(newGroup);
|
||||
if (currentPath && !groupName.startsWith(currentPath + '/')) {
|
||||
groupName = `${currentPath}/${groupName}`;
|
||||
}
|
||||
|
||||
const nextName = generateNextUntitledGroupName();
|
||||
setEditGroupName(nextName);
|
||||
const newGroup = addGroup(groupName);
|
||||
setNewGroup(newGroup);
|
||||
setSelectedGroup(newGroup);
|
||||
setShowInput(false);
|
||||
const parentGroup = getParentPath(groupName);
|
||||
if (parentGroup) {
|
||||
setCurrentPath(parentGroup);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,6 +129,19 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
setSelectedGroup((prevGroup) => (prevGroup?.id === group.id ? null : group));
|
||||
};
|
||||
|
||||
const handleNavigateToGroup = (group: BookGroupType) => {
|
||||
setCurrentPath(group.name);
|
||||
};
|
||||
|
||||
const handleNavigateBack = () => {
|
||||
const parent = currentPath ? getParentPath(currentPath) : undefined;
|
||||
setCurrentPath(parent);
|
||||
};
|
||||
|
||||
const handleNavigateToPath = (path: string | undefined) => {
|
||||
setCurrentPath(path);
|
||||
};
|
||||
|
||||
const handleConfirmGrouping = () => {
|
||||
selectedBooks.forEach((id) => {
|
||||
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
|
||||
@@ -113,6 +157,11 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
const getDisplayName = (fullPath: string) => {
|
||||
const segments = fullPath.split('/');
|
||||
return segments[segments.length - 1];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.select();
|
||||
@@ -130,10 +179,16 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
if (Array.from(new Set(groupIds)).length === 1) {
|
||||
setTimeout(() => {
|
||||
const allGroups = getGroups();
|
||||
setSelectedGroup(allGroups.find((group) => group.id === groupIds[0]) || null);
|
||||
const group = allGroups.find((group) => group.id === groupIds[0]);
|
||||
setSelectedGroup(group || null);
|
||||
if (group && !currentPath) {
|
||||
const parent = getParentPath(group.name);
|
||||
setCurrentPath(parent);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [libraryBooks, selectedBooks, getGroups]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 flex items-center justify-center'>
|
||||
@@ -144,6 +199,8 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
)}
|
||||
>
|
||||
<h2 className='text-center text-lg font-bold'>{_('Group Books')}</h2>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className={clsx('mt-4 grid grid-cols-1 gap-2 text-base md:grid-cols-2')}>
|
||||
{isSelectedBooksHasGroup && (
|
||||
<button
|
||||
@@ -162,62 +219,115 @@ const GroupingModal: React.FC<GroupingModalProps> = ({
|
||||
<span className='truncate'>{_('Create New Group')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create group input */}
|
||||
{showInput && (
|
||||
<div className='mt-4 flex items-center gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
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',
|
||||
editGroupName ? '' : 'btn-disabled !bg-opacity-0',
|
||||
)}
|
||||
onClick={() => handleConfirmCreateGroup()}
|
||||
>
|
||||
<div className='pr-1 align-bottom text-base text-blue-500 sm:text-sm'>
|
||||
{_('Save')}
|
||||
</div>
|
||||
</button>
|
||||
<div className='mt-4 space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
ref={editorRef}
|
||||
value={editGroupName}
|
||||
onChange={(e) => setEditGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleConfirmCreateGroup();
|
||||
if (e.key === 'Escape') setShowInput(false);
|
||||
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',
|
||||
editGroupName ? '' : 'btn-disabled !bg-opacity-0',
|
||||
)}
|
||||
onClick={() => handleConfirmCreateGroup()}
|
||||
>
|
||||
<div className='pr-1 align-bottom text-base text-blue-500 sm:text-sm'>
|
||||
{_('Save')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ul className='groups-list mt-4 grid grid-cols-2 gap-2'>
|
||||
{allGroups.map((group, index) => (
|
||||
|
||||
{/* Breadcrumb navigation */}
|
||||
{currentPath && (
|
||||
<div className='mt-4 flex flex-wrap items-center gap-2 text-base'>
|
||||
<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)}
|
||||
onClick={handleNavigateBack}
|
||||
className='hover:bg-base-300 flex items-center gap-1 rounded px-2 py-1'
|
||||
>
|
||||
<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 flex shrink-0 text-sm'>
|
||||
{selectedGroup && selectedGroup.id == group.id && (
|
||||
<MdCheck className='fill-blue-500' size={iconSize} />
|
||||
)}
|
||||
</span>
|
||||
<IoMdArrowBack size={iconSize} />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(undefined)}
|
||||
className='hover:bg-base-300 rounded px-2 py-1'
|
||||
>
|
||||
{_('All')}
|
||||
</button>
|
||||
{getBreadcrumbs(currentPath).map((crumb, index, array) => {
|
||||
const isLast = index === array.length - 1;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<MdChevronRight size={iconSize} className='text-neutral-content' />
|
||||
{isLast ? (
|
||||
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(crumb.path)}
|
||||
className='hover:bg-base-300 truncate rounded px-2 py-1'
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
<ul className='groups-list mt-4 grid grid-cols-2 gap-2 overflow-x-hidden'>
|
||||
{currentGroupsList.map((group, index) => {
|
||||
const displayName = getDisplayName(group.name);
|
||||
const hasChildren = allGroups.some((g) => g.name.startsWith(group.name + '/'));
|
||||
return (
|
||||
<div key={index} className='flex min-w-0 gap-1'>
|
||||
<button
|
||||
className={clsx(
|
||||
'hover:bg-base-300 text-base-content flex min-w-0 max-w-[90%] flex-1',
|
||||
'items-center justify-between gap-2 rounded-md px-2 py-2',
|
||||
)}
|
||||
onClick={() => handleToggleSelectGroup(group)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<span className='shrink-0'>
|
||||
<HiOutlineFolder size={iconSize} />
|
||||
</span>
|
||||
<span className='min-w-0 truncate text-base sm:text-sm'>{displayName}</span>
|
||||
</div>
|
||||
<span className='text-neutral-content flex shrink-0 text-sm'>
|
||||
{selectedGroup && selectedGroup.id === group.id && (
|
||||
<MdCheck className='fill-blue-500' size={iconSize} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => handleNavigateToGroup(group)}
|
||||
className='hover:bg-base-300 flex shrink-0 items-center rounded-md px-1'
|
||||
>
|
||||
<MdChevronRight size={iconSize} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className='mt-6 flex justify-end gap-x-8 p-2'>
|
||||
<button onClick={onCancel} className='flex items-center'>
|
||||
{_('Cancel')}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FaSearch } from 'react-icons/fa';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { PiSelectionAll, PiSelectionAllFill } from 'react-icons/pi';
|
||||
import { PiDotsThreeCircle } from 'react-icons/pi';
|
||||
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
|
||||
import { MdOutlineMenu } from 'react-icons/md';
|
||||
import { IoMdCloseCircle } from 'react-icons/io';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
@@ -15,7 +15,6 @@ import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useTrafficLightStore } from '@/store/trafficLightStore';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
@@ -60,7 +59,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
const viewSettings = settings.globalViewSettings;
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const iconSize20 = useResponsiveSize(20);
|
||||
const { safeAreaInsets: insets } = useThemeStore();
|
||||
|
||||
useShortcuts({
|
||||
@@ -100,7 +98,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
}, [appService?.hasTrafficLight]);
|
||||
|
||||
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
||||
const isInGroupView = !!searchParams?.get('group');
|
||||
const currentBooksCount = currentBookshelf.reduce(
|
||||
(acc, item) => acc + ('books' in item ? item.books.length : 1),
|
||||
0,
|
||||
@@ -124,18 +121,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
>
|
||||
<div className='flex w-full 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'>
|
||||
{isInGroupView && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigateToLibrary(router);
|
||||
}}
|
||||
className='ml-[-6px] mr-4 flex h-7 min-h-7 w-7 items-center p-0'
|
||||
>
|
||||
<div className='lg:tooltip lg:tooltip-bottom' data-tip={_('Go Back')}>
|
||||
<MdArrowBackIosNew size={iconSize20} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className='relative flex h-9 w-full items-center sm:h-7'>
|
||||
<span className='text-base-content/50 absolute left-3'>
|
||||
<FaSearch className='h-4 w-4' />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import clsx from 'clsx';
|
||||
import * as React from 'react';
|
||||
import { MdChevronRight } from 'react-icons/md';
|
||||
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
|
||||
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
|
||||
@@ -9,7 +10,7 @@ import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService, DeleteAction } from '@/types/system';
|
||||
import { navigateToLogin, navigateToReader } from '@/utils/nav';
|
||||
import { navigateToLibrary, navigateToLogin, navigateToReader } from '@/utils/nav';
|
||||
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { ProgressPayload } from '@/utils/transfer';
|
||||
@@ -27,6 +28,7 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
@@ -50,6 +52,7 @@ import { UpdaterWindow } from '@/components/UpdaterWindow';
|
||||
import { MigrateDataWindow } from './components/MigrateDataWindow';
|
||||
import { useDragDropImport } from './hooks/useDragDropImport';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { getBreadcrumbs } from './utils/libraryUtils';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
@@ -72,6 +75,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
syncProgress,
|
||||
updateBook,
|
||||
setLibrary,
|
||||
getGroupId,
|
||||
getGroupName,
|
||||
refreshGroups,
|
||||
checkOpenWithBooks,
|
||||
@@ -90,12 +94,14 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
const [isSelectNone, setIsSelectNone] = useState(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [currentGroupPath, setCurrentGroupPath] = useState<string | undefined>(undefined);
|
||||
const [booksTransferProgress, setBooksTransferProgress] = useState<{
|
||||
[key: string]: number | null;
|
||||
}>({});
|
||||
const [pendingNavigationBookIds, setPendingNavigationBookIds] = useState<string[] | null>(null);
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
const iconSize = useResponsiveSize(18);
|
||||
const viewSettings = settings.globalViewSettings;
|
||||
const demoBooks = useDemoBooks();
|
||||
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
||||
@@ -325,6 +331,12 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const group = searchParams?.get('group') || '';
|
||||
const groupName = getGroupName(group);
|
||||
setCurrentGroupPath(groupName);
|
||||
}, [libraryBooks, searchParams, getGroupName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (demoBooks.length > 0 && libraryLoaded) {
|
||||
const newLibrary = [...libraryBooks];
|
||||
@@ -594,6 +606,20 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
setShowDetailsBook(book);
|
||||
};
|
||||
|
||||
const handleNavigateToPath = (path: string | undefined) => {
|
||||
const group = path ? getGroupId(path) : '';
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
if (group) {
|
||||
params.set('group', group);
|
||||
} else {
|
||||
params.delete('group');
|
||||
}
|
||||
navigateToLibrary(router, `${params.toString()}`);
|
||||
setTimeout(() => {
|
||||
setCurrentGroupPath(path);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
|
||||
return <div className={clsx('h-[100vh]', !appService?.isLinuxApp && 'bg-base-200')} />;
|
||||
}
|
||||
@@ -630,6 +656,40 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
{currentGroupPath && (
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-y-1 px-4 text-base'>
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(undefined)}
|
||||
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
|
||||
>
|
||||
{_('All')}
|
||||
</button>
|
||||
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
|
||||
const isLast = index === array.length - 1;
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<MdChevronRight size={iconSize} className='text-neutral-content' />
|
||||
{isLast ? (
|
||||
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleNavigateToPath(crumb.path)}
|
||||
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<OverlayScrollbarsComponent
|
||||
|
||||
@@ -51,3 +51,12 @@ export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
}
|
||||
};
|
||||
|
||||
export const getBreadcrumbs = (currentPath: string) => {
|
||||
if (!currentPath) return [];
|
||||
const segments = currentPath.split('/');
|
||||
return segments.map((segment, index) => ({
|
||||
name: segment,
|
||||
path: segments.slice(0, index + 1).join('/'),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ export const Toast = () => {
|
||||
'max-h-[50vh] min-w-32 overflow-y-auto',
|
||||
'text-center font-sans text-base font-normal sm:text-sm',
|
||||
toastType === 'info'
|
||||
? 'max-w-[80vw]'
|
||||
? 'max-w-[80vw] overflow-x-hidden'
|
||||
: 'min-w-[60vw] max-w-[80vw] whitespace-normal break-words sm:min-w-40 sm:max-w-80',
|
||||
messageClass,
|
||||
)}
|
||||
|
||||
@@ -24,11 +24,13 @@ interface LibraryState {
|
||||
setLibrary: (books: Book[]) => void;
|
||||
updateBook: (envConfig: EnvConfigType, book: Book) => void;
|
||||
setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => void;
|
||||
|
||||
refreshGroups: () => void;
|
||||
addGroup: (name: string) => BookGroupType;
|
||||
getGroups: () => BookGroupType[];
|
||||
getGroupId: (path: string) => string | undefined;
|
||||
getGroupName: (id: string) => string | undefined;
|
||||
getParentPath: (path: string) => string | undefined;
|
||||
getGroupsByParent: (parentPath?: string) => BookGroupType[];
|
||||
}
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
@@ -40,15 +42,22 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
groups: {},
|
||||
checkOpenWithBooks: isTauriAppPlatform(),
|
||||
checkLastOpenBooks: isTauriAppPlatform(),
|
||||
|
||||
setIsSyncing: (syncing: boolean) => set({ isSyncing: syncing }),
|
||||
setSyncProgress: (progress: number) => set({ syncProgress: progress }),
|
||||
getVisibleLibrary: () => get().library.filter((book) => !book.deletedAt),
|
||||
|
||||
setCurrentBookshelf: (bookshelf: (Book | BooksGroup)[]) => {
|
||||
set({ currentBookshelf: bookshelf });
|
||||
},
|
||||
|
||||
setCheckOpenWithBooks: (check) => set({ checkOpenWithBooks: check }),
|
||||
setCheckLastOpenBooks: (check) => set({ checkLastOpenBooks: check }),
|
||||
setLibrary: (books) => set({ library: books }),
|
||||
setLibrary: (books) => {
|
||||
const { refreshGroups } = get();
|
||||
set({ library: books });
|
||||
refreshGroups();
|
||||
},
|
||||
updateBook: async (envConfig: EnvConfigType, book: Book) => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const { library } = get();
|
||||
@@ -59,12 +68,15 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
set({ library: [...library] });
|
||||
await appService.saveLibraryBooks(library);
|
||||
},
|
||||
|
||||
setSelectedBooks: (ids: string[]) => {
|
||||
set({ selectedBooks: new Set(ids) });
|
||||
},
|
||||
|
||||
getSelectedBooks: () => {
|
||||
return Array.from(get().selectedBooks);
|
||||
},
|
||||
|
||||
toggleSelectedBook: (id: string) => {
|
||||
set((state) => {
|
||||
const newSelection = new Set(state.selectedBooks);
|
||||
@@ -76,6 +88,7 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
return { selectedBooks: newSelection };
|
||||
});
|
||||
},
|
||||
|
||||
refreshGroups: () => {
|
||||
const { library } = get();
|
||||
const groups: Record<string, string> = {};
|
||||
@@ -89,11 +102,19 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
!book.deletedAt
|
||||
) {
|
||||
groups[book.groupId] = book.groupName;
|
||||
let nextSlashIndex = book.groupName.indexOf('/', 0);
|
||||
while (nextSlashIndex > 0) {
|
||||
const groupName = book.groupName.substring(0, nextSlashIndex);
|
||||
const groupId = md5Fingerprint(groupName);
|
||||
groups[groupId] = groupName;
|
||||
nextSlashIndex = book.groupName.indexOf('/', nextSlashIndex + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
set({ groups });
|
||||
},
|
||||
|
||||
addGroup: (name: string) => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
@@ -107,13 +128,44 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
|
||||
return { id, name: trimmedName };
|
||||
},
|
||||
|
||||
getGroups: () => {
|
||||
const { groups } = get();
|
||||
return Object.entries(groups)
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
},
|
||||
|
||||
getGroupId: (path: string) => {
|
||||
const { groups } = get();
|
||||
|
||||
const directId = Object.entries(groups).find(([_, name]) => name === path)?.[0];
|
||||
if (directId) {
|
||||
return directId;
|
||||
}
|
||||
|
||||
return md5Fingerprint(path);
|
||||
},
|
||||
|
||||
getGroupName: (id: string) => {
|
||||
return get().groups[id];
|
||||
},
|
||||
|
||||
getParentPath: (path: string) => {
|
||||
const lastSlashIndex = path.lastIndexOf('/');
|
||||
if (lastSlashIndex === -1) return '';
|
||||
return path.slice(0, lastSlashIndex);
|
||||
},
|
||||
|
||||
getGroupsByParent: (parentPath?: string) => {
|
||||
const { groups } = get();
|
||||
const result: BookGroupType[] = [];
|
||||
Object.entries(groups).forEach(([id, name]) => {
|
||||
const groupParentPath = get().getParentPath(name);
|
||||
if (groupParentPath === (parentPath || '')) {
|
||||
result.push({ id, name });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -266,6 +266,7 @@ export interface BookDataRecord {
|
||||
export interface BooksGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
books: Book[];
|
||||
|
||||
updatedAt: number;
|
||||
|
||||
Reference in New Issue
Block a user