From 14d1b35d8790ac86a3bc9618c823524125bb011d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 07:55:55 +0800 Subject: [PATCH] Add expandable library sidebar filters --- .../public/locales/en/translation.json | 4 + .../public/locales/zh-CN/translation.json | 4 + .../public/locales/zh-TW/translation.json | 4 + .../src/app/library/components/Bookshelf.tsx | 18 +- .../app/library/components/LibrarySidebar.tsx | 316 ++++++++++++++++++ apps/readest-app/src/app/library/page.tsx | 240 +++++++------ .../src/app/library/utils/categoryFilters.ts | 93 ++++++ 7 files changed, 572 insertions(+), 107 deletions(-) create mode 100644 apps/readest-app/src/app/library/components/LibrarySidebar.tsx create mode 100644 apps/readest-app/src/app/library/utils/categoryFilters.ts diff --git a/apps/readest-app/public/locales/en/translation.json b/apps/readest-app/public/locales/en/translation.json index a95976d4..ee777810 100644 --- a/apps/readest-app/public/locales/en/translation.json +++ b/apps/readest-app/public/locales/en/translation.json @@ -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", diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index ad49bd70..a4e95772 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -11,6 +11,10 @@ "Behavior": "行为", "Bilingual": "双语拆分", "Book": "书籍", + "All Books": "全部书籍", + "Categories": "分类", + "Category": "类别", + "Publication Year": "出版年份", "Bookmark": "书签", "Cancel": "取消", "Chapter": "章节", diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 624a2146..90f9365e 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -11,6 +11,10 @@ "Behavior": "行為", "Bilingual": "雙語拆分", "Book": "書籍", + "All Books": "全部書籍", + "Categories": "分類", + "Category": "類別", + "Publication Year": "出版年份", "Bookmark": "書籤", "Cancel": "取消", "Chapter": "章節", diff --git a/apps/readest-app/src/app/library/components/Bookshelf.tsx b/apps/readest-app/src/app/library/components/Bookshelf.tsx index 4179e51e..c2f35e14 100644 --- a/apps/readest-app/src/app/library/components/Bookshelf.tsx +++ b/apps/readest-app/src/app/library/components/Bookshelf.tsx @@ -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; booksTransferProgress: { [key: string]: number | null }; + categoryFilter?: LibraryCategoryFilter; } /** @@ -175,6 +177,7 @@ const Bookshelf: React.FC = ({ handleLibraryNavigation, handlePushLibrary, booksTransferProgress, + categoryFilter = 'all', }) => { const _ = useTranslation(); const router = useRouter(); @@ -185,6 +188,7 @@ const Bookshelf: React.FC = ({ 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 = ({ [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) { diff --git a/apps/readest-app/src/app/library/components/LibrarySidebar.tsx b/apps/readest-app/src/app/library/components/LibrarySidebar.tsx new file mode 100644 index 00000000..4f02bacd --- /dev/null +++ b/apps/readest-app/src/app/library/components/LibrarySidebar.tsx @@ -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 = ({ + 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>( + () => 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) => { + 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) => { + 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 ( + + ); +}; + +export default LibrarySidebar; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 1faf3d46..6c9a0f89 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -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 (
-
- setShowImportFromUrl(true) : undefined} - onOpenCatalogManager={handleShowOPDSDialog} - onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)} - onSelectAll={handleSelectAll} - onDeselectAll={handleDeselectAll} - /> + setShowImportFromUrl(true) : undefined} + onOpenCatalogManager={handleShowOPDSDialog} + onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)} + /> +
+
+ setShowImportFromUrl(true) : undefined + } + onOpenCatalogManager={handleShowOPDSDialog} + onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)} + onSelectAll={handleSelectAll} + onDeselectAll={handleDeselectAll} + /> + +
- {(loading || isSyncing) && ( -
- -
- )} - {currentGroupPath && ( -
-
- - {getBreadcrumbs(currentGroupPath).map((crumb, index, array) => { - const isLast = index === array.length - 1; - return ( - - - {isLast ? ( - {crumb.name} - ) : ( - - )} - - ); - })} + {(loading || isSyncing) && ( +
+
-
- )} - {currentSeriesAuthorGroup && ( - - )} - {showBookshelf && - (libraryBooks.some((book) => !book.deletedAt) ? ( -
-
- - + )} + {currentGroupPath && ( +
+
+ + {getBreadcrumbs(currentGroupPath).map((crumb, index, array) => { + const isLast = index === array.length - 1; + return ( + + + {isLast ? ( + {crumb.name} + ) : ( + + )} + + ); + })}
- ) : ( -
- - -
- ))} + )} + {currentSeriesAuthorGroup && ( + + )} + {showBookshelf && + (libraryBooks.some((book) => !book.deletedAt) ? ( +
+
+ + +
+
+ ) : ( +
+ + +
+ ))} +
{showDetailsBook && ( + 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(); + 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)); +};