forked from akai/readest
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user