Add expandable library sidebar filters
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
This commit is contained in:
@@ -69,6 +69,10 @@
|
||||
"Imported {{count}} annotations_one": "Imported {{count}} annotation",
|
||||
"Imported {{count}} annotations_other": "Imported {{count}} annotations",
|
||||
"Recently read": "Recently read",
|
||||
"All Books": "All Books",
|
||||
"Categories": "Categories",
|
||||
"Category": "Category",
|
||||
"Publication Year": "Publication Year",
|
||||
"Bilingual": "Bilingual",
|
||||
"Show recently read": "Show recently read",
|
||||
"Your books will appear here": "Your books will appear here",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"Behavior": "行为",
|
||||
"Bilingual": "双语拆分",
|
||||
"Book": "书籍",
|
||||
"All Books": "全部书籍",
|
||||
"Categories": "分类",
|
||||
"Category": "类别",
|
||||
"Publication Year": "出版年份",
|
||||
"Bookmark": "书签",
|
||||
"Cancel": "取消",
|
||||
"Chapter": "章节",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"Behavior": "行為",
|
||||
"Bilingual": "雙語拆分",
|
||||
"Book": "書籍",
|
||||
"All Books": "全部書籍",
|
||||
"Categories": "分類",
|
||||
"Category": "類別",
|
||||
"Publication Year": "出版年份",
|
||||
"Bookmark": "書籤",
|
||||
"Cancel": "取消",
|
||||
"Chapter": "章節",
|
||||
|
||||
@@ -50,6 +50,7 @@ import { eventDispatcher } from '@/utils/event';
|
||||
import { getLocalBookFilename } from '@/utils/book';
|
||||
import { MIMETYPES, EXTS } from '@/libs/document';
|
||||
import { makeSafeFilename } from '@/utils/misc';
|
||||
import { getBookCategoryValues, type LibraryCategoryFilter } from '../utils/categoryFilters';
|
||||
|
||||
import { useSpatialNavigation } from '../hooks/useSpatialNavigation';
|
||||
import DeleteConfirmAlert from '@/components/DeleteConfirmAlert';
|
||||
@@ -88,6 +89,7 @@ interface BookshelfProps {
|
||||
handleLibraryNavigation: (targetGroup: string) => void;
|
||||
handlePushLibrary: () => Promise<void>;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
categoryFilter?: LibraryCategoryFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +177,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleLibraryNavigation,
|
||||
handlePushLibrary,
|
||||
booksTransferProgress,
|
||||
categoryFilter = 'all',
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -185,6 +188,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
|
||||
const groupId = searchParams?.get('group') || '';
|
||||
const queryTerm = searchParams?.get('q') || null;
|
||||
const categoryValue = searchParams?.get('categoryValue') || '';
|
||||
const viewMode = searchParams?.get('view') || settings.libraryViewMode;
|
||||
const storedSortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy);
|
||||
const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
|
||||
@@ -249,10 +253,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const categoryFilteredBooks = useMemo(() => {
|
||||
if (categoryFilter === 'all' || !categoryValue) return libraryBooks;
|
||||
return libraryBooks.filter(
|
||||
(book) =>
|
||||
!book.deletedAt && getBookCategoryValues(book, categoryFilter).includes(categoryValue),
|
||||
);
|
||||
}, [libraryBooks, categoryFilter, categoryValue]);
|
||||
|
||||
const filteredBooks = useMemo(() => {
|
||||
const bookFilter = createBookFilter(queryTerm);
|
||||
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks;
|
||||
}, [libraryBooks, queryTerm]);
|
||||
return queryTerm
|
||||
? categoryFilteredBooks.filter((book) => bookFilter(book))
|
||||
: categoryFilteredBooks;
|
||||
}, [categoryFilteredBooks, queryTerm]);
|
||||
|
||||
const currentBookshelfItems = useMemo(() => {
|
||||
if (groupBy === LibraryGroupByType.Group) {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
PiBooks,
|
||||
PiCalendarBlank,
|
||||
PiDotsThreeCircle,
|
||||
PiGear,
|
||||
PiGlobeHemisphereEast,
|
||||
PiHouseLine,
|
||||
PiPlus,
|
||||
PiSelectionAll,
|
||||
PiSelectionAllFill,
|
||||
PiStackSimple,
|
||||
PiTag,
|
||||
PiUser,
|
||||
} from 'react-icons/pi';
|
||||
import { MdBusiness, MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineMenu } from 'react-icons/md';
|
||||
import { IoMdCloseCircle } from 'react-icons/io';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { navigateToLibrary } from '@/utils/nav';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import ImportMenu from './ImportMenu';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
import ViewMenu from './ViewMenu';
|
||||
import type { Book } from '@/types/book';
|
||||
import {
|
||||
countCategoryValues,
|
||||
ensureLibraryCategoryFilter,
|
||||
type CategoryValue,
|
||||
type LibraryCategoryFilter,
|
||||
} from '../utils/categoryFilters';
|
||||
|
||||
interface LibrarySidebarProps {
|
||||
books: Book[];
|
||||
isSelectMode: boolean;
|
||||
onPullLibrary: () => void;
|
||||
onImportBooksFromFiles: () => void;
|
||||
onImportBooksFromDirectory?: () => void;
|
||||
onImportBookFromUrl?: () => void;
|
||||
onOpenCatalogManager: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
}
|
||||
|
||||
type CategoryItem = {
|
||||
id: LibraryCategoryFilter;
|
||||
label: string;
|
||||
count: number;
|
||||
Icon: IconType;
|
||||
values: CategoryValue[];
|
||||
};
|
||||
|
||||
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
books,
|
||||
isSelectMode,
|
||||
onPullLibrary,
|
||||
onImportBooksFromFiles,
|
||||
onImportBooksFromDirectory,
|
||||
onImportBookFromUrl,
|
||||
onOpenCatalogManager,
|
||||
onToggleSelectMode,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
|
||||
const activeCategory = ensureLibraryCategoryFilter(searchParams?.get('category'));
|
||||
const activeCategoryValue = searchParams?.get('categoryValue') || '';
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<LibraryCategoryFilter>>(
|
||||
() => new Set(activeCategory !== 'all' ? [activeCategory] : ['author']),
|
||||
);
|
||||
|
||||
const activeBooks = useMemo(() => books.filter((book) => !book.deletedAt), [books]);
|
||||
const categories: CategoryItem[] = useMemo(() => {
|
||||
const makeCategory = (
|
||||
id: LibraryCategoryFilter,
|
||||
label: string,
|
||||
Icon: IconType,
|
||||
): CategoryItem => {
|
||||
const values = countCategoryValues(activeBooks, id);
|
||||
return { id, label, count: values.length, Icon, values };
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
label: _('All Books'),
|
||||
count: activeBooks.length,
|
||||
Icon: PiBooks,
|
||||
values: [],
|
||||
},
|
||||
makeCategory('author', _('Authors'), PiUser),
|
||||
makeCategory('publisher', _('Publisher'), MdBusiness),
|
||||
makeCategory('year', _('Publication Year'), PiCalendarBlank),
|
||||
makeCategory('language', _('Language'), PiGlobeHemisphereEast),
|
||||
makeCategory('format', _('Category'), PiStackSimple),
|
||||
makeCategory('subject', _('Subject'), PiHouseLine),
|
||||
makeCategory('tag', _('Tags'), PiTag),
|
||||
];
|
||||
}, [_, activeBooks]);
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!value) {
|
||||
params.delete(key);
|
||||
} else {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
if (params.get('category') === 'all') params.delete('category');
|
||||
navigateToLibrary(router, params.toString());
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdateQueryParam = useCallback(
|
||||
debounce((value: string) => updateParams({ q: value || null }), 500),
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newQuery = event.target.value;
|
||||
setSearchQuery(newQuery);
|
||||
debouncedUpdateQueryParam(newQuery);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
debouncedUpdateQueryParam('');
|
||||
};
|
||||
|
||||
const toggleCategory = (category: LibraryCategoryFilter) => {
|
||||
if (category === 'all') {
|
||||
updateParams({ category: null, categoryValue: null, group: null });
|
||||
return;
|
||||
}
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectCategoryValue = (category: LibraryCategoryFilter, value: string) => {
|
||||
updateParams({ category, categoryValue: value, group: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'library-sidebar bg-base-200/80 border-base-300/70 hidden h-full w-[248px] shrink-0 flex-col border-r md:flex',
|
||||
'pt-[max(env(safe-area-inset-top),0px)]',
|
||||
)}
|
||||
>
|
||||
<div className='exclude-title-bar-mousedown flex min-h-0 flex-1 flex-col px-3 py-3'>
|
||||
<div className='mb-3 flex h-8 items-center gap-2 px-1'>
|
||||
<MdOutlineMenu className='text-base-content/70 h-5 w-5 shrink-0' />
|
||||
<div className='min-w-0 flex-1 truncate text-sm font-semibold'>Readest</div>
|
||||
</div>
|
||||
|
||||
<div className='relative mb-3 flex h-8 items-center'>
|
||||
<span className='text-base-content/45 absolute left-3'>
|
||||
<FaSearch className='h-3.5 w-3.5' />
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
placeholder={_('Search Books...')}
|
||||
onChange={handleSearchChange}
|
||||
spellCheck='false'
|
||||
className={clsx(
|
||||
'input input-sm bg-base-100 border-base-300 h-8 w-full rounded-md ps-9 pe-8',
|
||||
'text-sm placeholder:text-base-content/45 focus:outline-none',
|
||||
)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={clearSearch}
|
||||
className='text-base-content/40 hover:text-base-content/70 absolute right-2'
|
||||
aria-label={_('Clear Search')}
|
||||
>
|
||||
<IoMdCloseCircle className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className='min-h-0 flex-1 overflow-y-auto pb-3' aria-label={_('Categories')}>
|
||||
<div className='space-y-1'>
|
||||
{categories.map(({ id, label, count, Icon, values }) => {
|
||||
const active = (activeCategory || 'all') === id && !activeCategoryValue;
|
||||
const expanded = expandedCategories.has(id);
|
||||
return (
|
||||
<div key={id}>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => toggleCategory(id)}
|
||||
className={clsx(
|
||||
'flex h-9 w-full items-center gap-3 rounded-md px-2 text-sm transition-colors',
|
||||
active
|
||||
? 'bg-base-300 text-base-content'
|
||||
: 'text-base-content/85 hover:bg-base-300/55',
|
||||
)}
|
||||
>
|
||||
<Icon className='h-4.5 w-4.5 shrink-0' />
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
|
||||
{id !== 'all' &&
|
||||
(expanded ? (
|
||||
<MdKeyboardArrowUp className='text-base-content/60 h-4 w-4 shrink-0' />
|
||||
) : (
|
||||
<MdKeyboardArrowDown className='text-base-content/60 h-4 w-4 shrink-0' />
|
||||
))}
|
||||
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
{id !== 'all' && expanded && values.length > 0 && (
|
||||
<div className='mt-1 space-y-1 pb-1 ps-7'>
|
||||
{values.map((item) => {
|
||||
const valueActive =
|
||||
activeCategory === id && activeCategoryValue === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type='button'
|
||||
onClick={() => selectCategoryValue(id, item.value)}
|
||||
className={clsx(
|
||||
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm transition-colors',
|
||||
valueActive
|
||||
? 'bg-base-300 text-base-content'
|
||||
: 'text-base-content/80 hover:bg-base-300/45',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'h-4 w-0.5 rounded-full',
|
||||
valueActive ? 'bg-primary' : 'bg-transparent',
|
||||
)}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate text-left'>{item.value}</span>
|
||||
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
|
||||
{item.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className='border-base-300/70 flex items-center gap-1 border-t pt-2'>
|
||||
<Dropdown
|
||||
label={_('Import Books')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiPlus role='none' size={iconSize18} />}
|
||||
>
|
||||
<ImportMenu
|
||||
onImportBooksFromFiles={onImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={onImportBooksFromDirectory}
|
||||
onImportBookFromUrl={onImportBookFromUrl}
|
||||
onOpenCatalogManager={onOpenCatalogManager}
|
||||
/>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
label={_('View Menu')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
|
||||
>
|
||||
<ViewMenu />
|
||||
</Dropdown>
|
||||
<button
|
||||
onClick={onToggleSelectMode}
|
||||
aria-label={_('Select Books')}
|
||||
title={_('Select Books')}
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
>
|
||||
{isSelectMode ? (
|
||||
<PiSelectionAllFill className='h-[18px] w-[18px]' />
|
||||
) : (
|
||||
<PiSelectionAll className='h-[18px] w-[18px]' />
|
||||
)}
|
||||
</button>
|
||||
<div className='flex-1' />
|
||||
<Dropdown
|
||||
label={_('Settings Menu')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiGear role='none' size={iconSize18} />}
|
||||
>
|
||||
<SettingsMenu onPullLibrary={onPullLibrary} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibrarySidebar;
|
||||
@@ -100,6 +100,8 @@ import {
|
||||
} from './utils/libraryUtils';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import LibraryHeader from './components/LibraryHeader';
|
||||
import LibrarySidebar from './components/LibrarySidebar';
|
||||
import { ensureLibraryCategoryFilter } from './utils/categoryFilters';
|
||||
import Bookshelf from './components/Bookshelf';
|
||||
import LibraryEmptyState from './components/LibraryEmptyState';
|
||||
import GroupHeader from './components/GroupHeader';
|
||||
@@ -1592,133 +1594,161 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}
|
||||
|
||||
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
|
||||
const categoryFilter = ensureLibraryCategoryFilter(searchParams?.get('category'));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageRef}
|
||||
aria-label={_('Your Library')}
|
||||
className={clsx(
|
||||
'library-page text-base-content full-height flex select-none flex-col overflow-hidden',
|
||||
'library-page text-base-content full-height flex select-none overflow-hidden',
|
||||
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
||||
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='relative top-0 z-40 w-full'
|
||||
role='banner'
|
||||
tabIndex={-1}
|
||||
aria-label={_('Library Header')}
|
||||
>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
/>
|
||||
<LibrarySidebar
|
||||
books={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
/>
|
||||
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
|
||||
<div
|
||||
className='relative top-0 z-40 w-full md:hidden'
|
||||
role='banner'
|
||||
tabIndex={-1}
|
||||
aria-label={_('Library Header')}
|
||||
>
|
||||
<LibraryHeader
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={
|
||||
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
|
||||
}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
/>
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
</div>
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
|
||||
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
</div>
|
||||
{(loading || isSyncing) && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{(loading || isSyncing) && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentSeriesAuthorGroup && (
|
||||
<GroupHeader
|
||||
groupBy={currentSeriesAuthorGroup.groupBy}
|
||||
groupName={currentSeriesAuthorGroup.groupName}
|
||||
/>
|
||||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
}}
|
||||
>
|
||||
<DropIndicator />
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
onScrollerRef={handleScrollerRef}
|
||||
handleImportBooks={handleImportBooksFromFiles}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete('both')}
|
||||
handleBookPurge={handleBookDelete('purge')}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
)}
|
||||
{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 py-2 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>
|
||||
) : (
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{currentSeriesAuthorGroup && (
|
||||
<GroupHeader
|
||||
groupBy={currentSeriesAuthorGroup.groupBy}
|
||||
groupName={currentSeriesAuthorGroup.groupName}
|
||||
/>
|
||||
)}
|
||||
{showBookshelf &&
|
||||
(libraryBooks.some((book) => !book.deletedAt) ? (
|
||||
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(
|
||||
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
|
||||
isDragging && 'drag-over',
|
||||
)}
|
||||
style={{
|
||||
paddingRight: `${insets.right}px`,
|
||||
paddingLeft: `${insets.left}px`,
|
||||
}}
|
||||
>
|
||||
<DropIndicator />
|
||||
<Bookshelf
|
||||
libraryBooks={libraryBooks}
|
||||
categoryFilter={categoryFilter}
|
||||
isSelectMode={isSelectMode}
|
||||
isSelectAll={isSelectAll}
|
||||
isSelectNone={isSelectNone}
|
||||
onScrollerRef={handleScrollerRef}
|
||||
handleImportBooks={handleImportBooksFromFiles}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
handleBookDelete={handleBookDelete('both')}
|
||||
handleBookPurge={handleBookDelete('purge')}
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='hero drop-zone h-screen items-center justify-center'>
|
||||
<DropIndicator />
|
||||
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<NowPlayingBar isSelectMode={isSelectMode} />
|
||||
{showDetailsBook && (
|
||||
<BookDetailModal
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Book } from '@/types/book';
|
||||
import { formatAuthors, formatLanguage, formatPublisher } from '@/utils/book';
|
||||
import { parseAuthors } from './libraryUtils';
|
||||
|
||||
export type LibraryCategoryFilter =
|
||||
| 'all'
|
||||
| 'author'
|
||||
| 'publisher'
|
||||
| 'year'
|
||||
| 'language'
|
||||
| 'format'
|
||||
| 'subject'
|
||||
| 'tag';
|
||||
|
||||
const LIBRARY_CATEGORY_FILTERS: LibraryCategoryFilter[] = [
|
||||
'all',
|
||||
'author',
|
||||
'publisher',
|
||||
'year',
|
||||
'language',
|
||||
'format',
|
||||
'subject',
|
||||
'tag',
|
||||
];
|
||||
|
||||
export type CategoryValue = {
|
||||
value: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const ensureLibraryCategoryFilter = (
|
||||
value: string | null | undefined,
|
||||
): LibraryCategoryFilter =>
|
||||
LIBRARY_CATEGORY_FILTERS.includes(value as LibraryCategoryFilter)
|
||||
? (value as LibraryCategoryFilter)
|
||||
: 'all';
|
||||
|
||||
const getPublishedYear = (book: Book) =>
|
||||
typeof book.metadata?.published === 'string'
|
||||
? book.metadata.published.match(/\d{4}/)?.[0]
|
||||
: undefined;
|
||||
|
||||
const getSubjects = (book: Book): string[] => {
|
||||
const subject = book.metadata?.subject;
|
||||
if (!subject) return [];
|
||||
if (Array.isArray(subject)) return subject.map((item) => `${item}`.trim()).filter(Boolean);
|
||||
if (typeof subject === 'string') {
|
||||
return subject
|
||||
.split(/[,;,、]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [formatAuthors(subject)].filter(Boolean);
|
||||
};
|
||||
|
||||
export const getBookCategoryValues = (book: Book, category: LibraryCategoryFilter): string[] => {
|
||||
switch (category) {
|
||||
case 'author':
|
||||
return parseAuthors(formatAuthors(book.author, book.primaryLanguage));
|
||||
case 'publisher': {
|
||||
const publisher = formatPublisher(book.metadata?.publisher || '').trim();
|
||||
return publisher ? [publisher] : [];
|
||||
}
|
||||
case 'year': {
|
||||
const year = getPublishedYear(book);
|
||||
return year ? [year] : [];
|
||||
}
|
||||
case 'language': {
|
||||
const language = formatLanguage(book.metadata?.language).trim();
|
||||
return language ? [language] : [];
|
||||
}
|
||||
case 'format':
|
||||
return book.format ? [book.format] : [];
|
||||
case 'subject':
|
||||
return getSubjects(book);
|
||||
case 'tag':
|
||||
return book.tags?.map((tag) => tag.trim()).filter(Boolean) || [];
|
||||
case 'all':
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const countCategoryValues = (books: Book[], category: LibraryCategoryFilter) => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const book of books) {
|
||||
for (const value of getBookCategoryValues(book, category)) {
|
||||
counts.set(value, (counts.get(value) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => a.value.localeCompare(b.value, navigator.language));
|
||||
};
|
||||
Reference in New Issue
Block a user