feat: add book metadata search in the bookshelf, closes #100 (#838)

This commit is contained in:
Huang Xin
2025-04-08 21:37:18 +08:00
committed by GitHub
parent 915f222e08
commit 37951c2ebf
21 changed files with 104 additions and 26 deletions
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} دقائق",
"{{value}} hour": "{{value}} ساعة",
"{{value}} hours": "{{value}} ساعات",
"CJK Font": "خط CJK"
"CJK Font": "خط CJK",
"Clear Search": "مسح البحث"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} Minuten",
"{{value}} hour": "{{value}} Stunde",
"{{value}} hours": "{{value}} Stunden",
"CJK Font": "CJK-Schriftart"
"CJK Font": "CJK-Schriftart",
"Clear Search": "Suche löschen"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} λεπτά",
"{{value}} hour": "{{value}} ώρα",
"{{value}} hours": "{{value}} ώρες",
"CJK Font": "Γραμματοσειρά CJK"
"CJK Font": "Γραμματοσειρά CJK",
"Clear Search": "Καθαρισμός αναζήτησης"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} minutos",
"{{value}} hour": "{{value}} hora",
"{{value}} hours": "{{value}} horas",
"CJK Font": "Fuente CJK"
"CJK Font": "Fuente CJK",
"Clear Search": "Limpiar búsqueda"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} minutes",
"{{value}} hour": "{{value}} heure",
"{{value}} hours": "{{value}} heures",
"CJK Font": "Police CJK"
"CJK Font": "Police CJK",
"Clear Search": "Effacer la recherche"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} मिनट",
"{{value}} hour": "{{value}} घंटा",
"{{value}} hours": "{{value}} घंटे",
"CJK Font": "CJK फ़ॉन्ट"
"CJK Font": "CJK फ़ॉन्ट",
"Clear Search": "खोज साफ करें"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} menit",
"{{value}} hour": "{{value}} jam",
"{{value}} hours": "{{value}} jam",
"CJK Font": "Font CJK"
"CJK Font": "Font CJK",
"Clear Search": "Hapus Pencarian"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} minuti",
"{{value}} hour": "{{value}} ora",
"{{value}} hours": "{{value}} ore",
"CJK Font": "Font CJK"
"CJK Font": "Font CJK",
"Clear Search": "Cancella ricerca"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}}分",
"{{value}} hour": "{{value}}時間",
"{{value}} hours": "{{value}}時間",
"CJK Font": "CJKフォント"
"CJK Font": "CJKフォント",
"Clear Search": "検索をクリア"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} 분",
"{{value}} hour": "{{value}} 시간",
"{{value}} hours": "{{value}} 시간",
"CJK Font": "CJK 글꼴"
"CJK Font": "CJK 글꼴",
"Clear Search": "검색 지우기"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} minut",
"{{value}} hour": "{{value}} godzina",
"{{value}} hours": "{{value}} godziny",
"CJK Font": "Czcionka CJK"
"CJK Font": "Czcionka CJK",
"Clear Search": "Wyczyść wyszukiwanie"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} minutos",
"{{value}} hour": "{{value}} hora",
"{{value}} hours": "{{value}} horas",
"CJK Font": "Fonte CJK"
"CJK Font": "Fonte CJK",
"Clear Search": "Limpar Pesquisa"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} минуты",
"{{value}} hour": "{{value}} час",
"{{value}} hours": "{{value}} часа",
"CJK Font": "CJK Шрифт"
"CJK Font": "CJK Шрифт",
"Clear Search": "Очистить поиск"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} dakika",
"{{value}} hour": "{{value}} saat",
"{{value}} hours": "{{value}} saat",
"CJK Font": "CJK Yazı Tipi"
"CJK Font": "CJK Yazı Tipi",
"Clear Search": "Aramayı Temizle"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} хвилин",
"{{value}} hour": "{{value}} година",
"{{value}} hours": "{{value}} години",
"CJK Font": "CJK шрифт"
"CJK Font": "CJK шрифт",
"Clear Search": "Очистити пошук"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}} phút",
"{{value}} hour": "{{value}} giờ",
"{{value}} hours": "{{value}} giờ",
"CJK Font": "Phông chữ CJK"
"CJK Font": "Phông chữ CJK",
"Clear Search": "Xóa tìm kiếm"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}}分钟",
"{{value}} hour": "{{value}}小时",
"{{value}} hours": "{{value}}小时",
"CJK Font": "中文字体"
"CJK Font": "中文字体",
"Clear Search": "清除搜索"
}
@@ -272,5 +272,6 @@
"{{value}} minutes": "{{value}}分鐘",
"{{value}} hour": "{{value}}小時",
"{{value}} hours": "{{value}}小時",
"CJK Font": "中文字體"
"CJK Font": "中文字體",
"Clear Search": "清除搜索"
}
@@ -10,6 +10,7 @@ import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { formatAuthors, formatTitle } from '@/utils/book';
import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
@@ -49,6 +50,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showSelectModeActions, setShowSelectModeActions] = useState(false);
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 isImportingBook = useRef(false);
@@ -86,6 +88,12 @@ const Bookshelf: React.FC<BookshelfProps> = ({
useEffect(() => {
const group = searchParams?.get('group') || '';
const query = searchParams?.get('q') || '';
if (query) {
setQueryTerm(query);
} else {
setQueryTerm(null);
}
if (group) {
const booksGroup = allBookshelfItems.find(
(item) => 'name' in item && item.id === group,
@@ -93,11 +101,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
if (booksGroup) {
setNavBooksGroup(booksGroup);
} else {
navigateToLibrary(router);
navigateToLibrary(router, query ? `q=${query}` : undefined);
}
} else {
setNavBooksGroup(null);
navigateToLibrary(router);
navigateToLibrary(router, query ? `q=${query}` : undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
@@ -137,6 +145,18 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const bookFilter = (item: Book, queryTerm: string) => {
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return searchTerm.test(title) || searchTerm.test(authors);
};
const filteredBookshelfItems = currentBookshelfItems.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
return true;
});
return (
<div className='bookshelf'>
@@ -146,7 +166,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12',
)}
>
{currentBookshelfItems.map((item, index) => (
{filteredBookshelfItems.map((item, index) => (
<BookshelfItem
key={`library-item-${index}`}
item={item}
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { navigateToLibrary, navigateToReader } from '@/utils/nav';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
@@ -94,6 +94,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { updateBook } = useLibraryStore();
@@ -133,7 +134,9 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
if (isSelectMode) {
toggleSelection(group.id);
} else {
navigateToLibrary(router, `group=${group.id}`);
const params = new URLSearchParams(searchParams?.toString());
params.set('group', group.id);
navigateToLibrary(router, `${params.toString()}`);
}
};
@@ -1,17 +1,18 @@
import clsx from 'clsx';
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { MdOutlineMenu } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
import { MdOutlineMenu, MdArrowBackIosNew } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTrafficLightStore } from '@/store/trafficLightStore';
import { navigateToLibrary } from '@/utils/nav';
import { throttle } from '@/utils/throttle';
import WindowButtons from '@/components/WindowButtons';
import Dropdown from '@/components/Dropdown';
import SettingsMenu from './SettingsMenu';
@@ -40,6 +41,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
setTrafficLightVisibility,
cleanupTrafficLightListeners,
} = useTrafficLightStore();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const headerRef = useRef<HTMLDivElement>(null);
const iconSize16 = useResponsiveSize(16);
@@ -49,6 +51,26 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledUpdateQueryParam = useCallback(
throttle((value: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (value) {
params.set('q', value);
} else {
params.delete('q');
}
router.push(`?${params.toString()}`);
}, 1000),
[searchParams],
);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setSearchQuery(newQuery);
throttledUpdateQueryParam(newQuery);
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
@@ -93,7 +115,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</span>
<input
type='text'
value={searchQuery}
placeholder={_('Search Books...')}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
@@ -103,6 +127,19 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
/>
</div>
<div className='absolute right-4 flex items-center space-x-2 text-gray-500 sm:space-x-4'>
{searchQuery && (
<button
type='button'
onClick={() => {
setSearchQuery('');
throttledUpdateQueryParam('');
}}
className='text-gray-400 hover:text-gray-600'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
</button>
)}
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
<Dropdown
className='exclude-title-bar-mousedown dropdown-bottom flex h-6 cursor-pointer justify-center'