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

This commit is contained in:
Codex
2026-07-09 07:55:55 +08:00
parent 21cad7ec09
commit 14d1b35d87
7 changed files with 572 additions and 107 deletions
@@ -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;
+135 -105
View File
@@ -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));
};