feat: show groups in list mode in the library page, closes #978 (#2009)

This commit is contained in:
Huang Xin
2025-09-10 00:18:54 +08:00
committed by GitHub
parent 01fde8573c
commit 932cfa3b9f
3 changed files with 192 additions and 25 deletions
@@ -19,7 +19,7 @@ import { isMd5 } from '@/utils/md5';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import ModalPortal from '@/components/ModalPortal';
import BookshelfItem, { generateGridItems, generateListItems } from './BookshelfItem';
import BookshelfItem, { generateBookshelfItems } from './BookshelfItem';
import GroupingModal from './GroupingModal';
interface BookshelfProps {
@@ -72,8 +72,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const allBookshelfItems =
viewMode === 'grid' ? generateGridItems(libraryBooks) : generateListItems(libraryBooks);
const allBookshelfItems = generateBookshelfItems(libraryBooks);
useEffect(() => {
if (isImportingBook.current) return;
@@ -20,7 +20,7 @@ import GroupItem from './GroupItem';
export type BookshelfItem = Book | BooksGroup;
export const generateGridItems = (books: Book[]): (Book | BooksGroup)[] => {
export const generateBookshelfItems = (books: Book[]): (Book | BooksGroup)[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
if (book.deletedAt) return acc;
book.groupId = book.groupId || BOOK_UNGROUPED_ID;
@@ -49,10 +49,6 @@ export const generateGridItems = (books: Book[]): (Book | BooksGroup)[] => {
return [...ungroupedBooks, ...groupedBooks].sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateListItems = (books: Book[]): (Book | BooksGroup)[] => {
return books.filter((book) => !book.deletedAt).sort((a, b) => b.updatedAt - a.updatedAt);
};
export const generateGroupsList = (items: Book[]): BookGroupType[] => {
return items
.sort((a, b) => b.updatedAt - a.updatedAt)
@@ -326,7 +322,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
mode === 'grid' && 'sm:hover:bg-base-300/50 flex h-full flex-col px-0 py-4 sm:px-4',
mode === 'list' && 'border-base-300 flex flex-col border-b py-2',
appService?.isMobileApp && 'no-context-menu',
pressing ? (mode === 'grid' ? 'scale-95' : 'scale-98') : 'scale-100',
pressing && mode === 'grid' ? 'scale-95' : 'scale-100',
)}
style={{
transition: 'transform 0.2s',
@@ -347,7 +343,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
showBookDetailsModal={showBookDetailsModal}
/>
) : (
<GroupItem group={item} isSelectMode={isSelectMode} groupSelected={itemSelected} />
<GroupItem
mode={mode}
group={item}
isSelectMode={isSelectMode}
groupSelected={itemSelected}
/>
)}
</div>
</div>
@@ -1,19 +1,99 @@
import clsx from 'clsx';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { useEffect, useRef, useState } from 'react';
import { MdCheckCircle, MdCheckCircleOutline, MdChevronRight, MdChevronLeft } from 'react-icons/md';
import { useEnv } from '@/context/EnvContext';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { BooksGroup } from '@/types/book';
import { LibraryViewModeType } from '@/types/settings';
import BookCover from '@/components/BookCover';
interface GroupItemProps {
mode: LibraryViewModeType;
group: BooksGroup;
isSelectMode: boolean;
groupSelected: boolean;
}
const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, groupSelected }) => {
const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupSelected }) => {
const { appService } = useEnv();
const iconSize15 = useResponsiveSize(15);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const checkScrollArrows = () => {
if (mode === 'list' && scrollContainerRef.current) {
const container = scrollContainerRef.current;
const hasOverflow = container.scrollWidth > container.clientWidth;
if (hasOverflow) {
const isAtStart = container.scrollLeft <= 5;
const isAtEnd = container.scrollLeft >= container.scrollWidth - container.clientWidth - 5;
setShowLeftArrow(!isAtStart);
setShowRightArrow(!isAtEnd);
} else {
setShowLeftArrow(false);
setShowRightArrow(false);
}
} else {
setShowLeftArrow(false);
setShowRightArrow(false);
}
};
useEffect(() => {
checkScrollArrows();
if (mode === 'list' && scrollContainerRef.current) {
const container = scrollContainerRef.current;
setTimeout(() => {
container.style.transform = 'translateZ(0)';
container.scrollLeft = 0;
}, 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, group.books.length, scrollContainerRef.current]);
const handleScroll = () => {
checkScrollArrows();
};
const scrollLeft = () => {
if (scrollContainerRef.current) {
const container = scrollContainerRef.current;
const scrollAmount = container.clientWidth * 0.5;
const currentScroll = container.scrollLeft;
const targetScroll = Math.max(0, currentScroll - scrollAmount);
container.scrollTo({ left: targetScroll, behavior: 'smooth' });
}
};
const scrollRight = () => {
if (scrollContainerRef.current) {
const container = scrollContainerRef.current;
const scrollAmount = container.clientWidth * 0.5;
const currentScroll = container.scrollLeft;
const maxScroll = container.scrollWidth - container.clientWidth;
const targetScroll = Math.min(maxScroll, currentScroll + scrollAmount);
container.scrollTo({ left: targetScroll, behavior: 'smooth' });
}
};
const stopEvent = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleLeftArrowClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
scrollLeft();
};
const handleRightArrowClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
scrollRight();
};
return (
<div
@@ -22,14 +102,99 @@ const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, groupSelecte
appService?.hasContextMenu ? 'cursor-pointer' : '',
)}
>
<div className='bg-base-100 relative flex aspect-[28/41] items-center justify-center overflow-hidden p-2 shadow-md'>
<div className='grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'>
{group.books.slice(0, 4).map((book) => (
<div key={book.hash} className='relative aspect-[28/41] h-full w-full'>
<BookCover book={book} isPreview />
<div
className={clsx(
'relative flex overflow-hidden',
mode === 'grid' && 'bg-base-100 aspect-[28/41] items-center justify-center p-2 shadow-md',
mode === 'list' && 'h-32 items-center justify-start gap-4 py-2',
)}
>
<div
className={clsx(
mode === 'grid' && 'flex h-full w-full',
mode === 'list' && 'relative min-w-0 max-w-[85%]',
)}
>
<div
ref={mode === 'list' ? scrollContainerRef : undefined}
className={clsx(
mode === 'grid' && 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden',
mode === 'list' && 'flex h-28 gap-2 overflow-x-auto overflow-y-hidden',
)}
style={
mode === 'list'
? {
scrollbarWidth: 'none',
msOverflowStyle: 'none',
WebkitOverflowScrolling: 'touch',
transform: 'translateZ(0)',
willChange: 'scroll-position',
}
: undefined
}
onScroll={mode === 'list' ? handleScroll : undefined}
>
{group.books.slice(0, mode === 'grid' ? 4 : undefined).map((book) => (
<div
key={book.hash}
className={clsx(
'relative aspect-[28/41] h-full',
mode === 'grid' && 'w-full',
mode === 'list' && 'flex-shrink-0',
)}
>
<BookCover book={book} isPreview />
</div>
))}
</div>
{mode === 'list' && showLeftArrow && (
<div className='absolute left-[-0.5px] top-0 h-full w-12'>
<div className='from-base-200/85 via-base-200/20 absolute inset-0 bg-gradient-to-r to-transparent'></div>
<button
onClick={handleLeftArrowClick}
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
className='absolute left-2 top-1/2 -translate-y-1/2 transition-all duration-200 hover:scale-110'
>
<div className='bg-base-100 border-base-content/10 hover:border-base-content/30 rounded-full border p-1 shadow-sm transition-colors duration-200'>
<MdChevronLeft
size={16}
className='text-base-content/50 hover:text-base-content/70'
/>
</div>
</button>
</div>
))}
)}
{mode === 'list' && showRightArrow && (
<div className='absolute right-[-0.5px] top-0 h-full w-12'>
<div className='from-base-200/85 via-base-200/20 absolute inset-0 bg-gradient-to-l to-transparent'></div>
<button
onClick={handleRightArrowClick}
onPointerDown={(e) => stopEvent(e)}
onPointerUp={(e) => stopEvent(e)}
onPointerMove={(e) => stopEvent(e)}
onPointerCancel={(e) => stopEvent(e)}
onPointerLeave={(e) => stopEvent(e)}
className='absolute right-2 top-1/2 -translate-y-1/2 transition-all duration-200 hover:scale-110'
>
<div className='bg-base-100 border-base-content/10 hover:border-base-content/30 rounded-full border p-1 shadow-sm transition-colors duration-200'>
<MdChevronRight
size={16}
className='text-base-content/50 hover:text-base-content/70'
/>
</div>
</button>
</div>
)}
</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}
</div>
)}
{groupSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
@@ -43,14 +208,16 @@ const GroupItem: React.FC<GroupItemProps> = ({ group, isSelectMode, groupSelecte
</div>
)}
</div>
<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}
</h4>
{mode === 'grid' && (
<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}
</h4>
</div>
<div className='placeholder' style={{ height: `${iconSize15}px` }}></div>
</div>
<div className='placeholder' style={{ height: `${iconSize15}px` }}></div>
</div>
)}
</div>
);
};