Add multiple book open and delete

This commit is contained in:
chrox
2024-10-27 17:35:27 +01:00
parent b30909a956
commit 6466a6a038
18 changed files with 353 additions and 89 deletions
@@ -42,6 +42,11 @@
},
"dialog:default",
"os:default",
"http:default"
"http:default",
"core:window:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize"
]
}
+2 -1
View File
@@ -18,7 +18,8 @@
"height": 600,
"resizable": true,
"fullscreen": false,
"maximized": true
"maximized": true,
"decorations": false
}
],
"security": {
+6
View File
@@ -37,3 +37,9 @@ foliate-view {
transform: scale(1.02);
transition: transform 0.3s ease;
}
.window-button {
@apply inline-flex h-6 w-6 items-center justify-center rounded-full;
@apply transition duration-200 ease-in-out transform hover:scale-105;
@apply bg-base-300 hover:bg-gray-300;
}
@@ -1,23 +1,20 @@
import * as React from 'react';
import Image from 'next/image';
import { FaPlus } from 'react-icons/fa';
import { MdDelete, MdOpenInNew } from 'react-icons/md';
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { Book, BooksGroup } from '@/types/book';
import { FaPlus } from 'react-icons/fa';
import Alert from '@/components/Alert';
import { useReaderStore } from '@/store/readerStore';
import { useEnv } from '@/context/EnvContext';
type BookshelfItem = Book | BooksGroup;
const UNGROUPED_NAME = 'ungrouped';
// const MOCK_BOOKS: Book[] = Array.from({ length: 14 }, (_v, k) => ({
// hash: `book-${k}`,
// format: 'EPUB',
// title: `Book ${k}`,
// author: `Author ${k}`,
// lastUpdated: Date.now() - 1000000 * k,
// coverImageUrl: `https://placehold.co/800?text=Book+${k}&font=roboto`,
// }));
const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
const groups: BooksGroup[] = books.reduce((acc: BooksGroup[], book: Book) => {
book.group = book.group || UNGROUPED_NAME;
@@ -42,27 +39,70 @@ const generateBookshelfItems = (books: Book[]): BookshelfItem[] => {
interface BookshelfProps {
libraryBooks: Book[];
isSelectMode: boolean;
onImportBooks: () => void;
}
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImportBooks }) => {
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImportBooks }) => {
const router = useRouter();
const { envConfig } = useEnv();
const { deleteBook } = useReaderStore();
const [selectedBooks, setSelectedBooks] = React.useState<string[]>([]);
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false);
React.useEffect(() => {
setSelectedBooks([]);
}, [isSelectMode]);
const bookshelfItems = generateBookshelfItems(libraryBooks);
const handleBookClick = (id: string) => {
router.push(`/reader?id=${id}`);
if (isSelectMode) {
toggleSelection(id);
} else {
router.push(`/reader?ids=${id}`);
}
};
const toggleSelection = (id: string) => {
setSelectedBooks((prev) =>
prev.includes(id) ? prev.filter((bookId) => bookId !== id) : [...prev, id],
);
};
const openSelectedBooks = () => {
router.push(`/reader?ids=${selectedBooks.join(',')}`);
};
const confirmDelete = () => {
for (const selectedBook of selectedBooks) {
const book = libraryBooks.find((b) => b.hash === selectedBook);
if (book) {
deleteBook(envConfig, book);
}
}
setSelectedBooks([]);
setShowDeleteAlert(false);
};
const deleteSelectedBooks = () => {
setShowDeleteAlert(true);
};
return (
<div className='bookshelf'>
{/* Books Grid */}
<div className='grid grid-cols-3 gap-6 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
<div className='grid grid-cols-3 gap-0 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8'>
{bookshelfItems.map((item, index) => (
<div key={`library-item-${index}`} className='hover:bg-base-200 flex h-full flex-col'>
<div
key={`library-item-${index}`}
className='flex h-full flex-col rounded-md p-4 hover:bg-gray-200'
>
<div className='flex-grow'>
{'format' in item ? (
<div className='bookItem cursor-pointer' onClick={() => handleBookClick(item.hash)}>
<div
className='book-item cursor-pointer'
onClick={() => handleBookClick(item.hash)}
>
<div key={(item as Book).hash} className='card bg-base-100 shadow-md'>
<div className='relative aspect-[28/41]'>
<Image
@@ -71,6 +111,21 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImportBooks }) =>
fill={true}
className='object-cover'
/>
{selectedBooks.includes(item.hash) && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{selectedBooks.includes(item.hash) ? (
<MdCheckCircle size={20} className='fill-blue-500' />
) : (
<MdCheckCircleOutline
size={20}
className='fill-gray-300 drop-shadow-sm'
/>
)}
</div>
)}
</div>
</div>
<div className='card-body p-0 pt-2'>
@@ -103,7 +158,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImportBooks }) =>
{bookshelfItems.length > 0 && (
<div
className='border-1 flex aspect-[28/41] items-center justify-center bg-white'
className='border-1 m-4 flex aspect-[28/41] items-center justify-center bg-white hover:bg-gray-200'
role='button'
onClick={onImportBooks}
>
@@ -111,6 +166,26 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImportBooks }) =>
</div>
)}
</div>
{selectedBooks.length > 0 && (
<div className='fixed bottom-4 left-1/2 flex -translate-x-1/2 transform space-x-4 rounded-lg bg-gray-800 p-4 text-white shadow-lg'>
<button onClick={openSelectedBooks} className='flex items-center space-x-2'>
<MdOpenInNew />
<span>Open</span>
</button>
<button onClick={deleteSelectedBooks} className='flex items-center space-x-2'>
<MdDelete className='fill-red-500' />
<span className='text-red-500'>Delete</span>
</button>
</div>
)}
{showDeleteAlert && (
<Alert
title='Confirm Deletion'
message='Are you sure to delete the selected books?'
onClickCancel={() => setShowDeleteAlert(false)}
onClickConfirm={confirmDelete}
/>
)}
</div>
);
};
@@ -0,0 +1,53 @@
import React from 'react';
import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { MdSelectAll } from 'react-icons/md';
import WindowButtons from '@/components/WindowButtons';
interface LibraryHeaderProps {
onImportBooks: () => void;
onToggleSelectMode: () => void;
}
const LibraryHeader: React.FC<LibraryHeaderProps> = ({ onImportBooks, onToggleSelectMode }) => {
return (
<div id='titlebar' className='titlebar fixed top-0 z-10 w-full bg-gray-100 px-8 py-6'>
<div className='flex items-center justify-between'>
<div className='sm:w relative flex w-full items-center'>
<span className='absolute left-4 text-gray-500'>
<FaSearch className='h-4 w-4' />
</span>
<input
type='text'
placeholder='Search books...'
className='input input-sm rounded-badge w-full bg-gray-200 pl-10 pr-10 text-base focus:border-none focus:outline-none'
/>
<div className='absolute right-4 flex items-center space-x-4 text-gray-500'>
<span className='mx-2 h-5 w-[1px] bg-gray-300'></span>
<span className='dropdown dropdown-end h-5 cursor-pointer text-gray-500'>
<div className='lg:tooltip lg:tooltip-bottom' data-tip='Add books'>
<PiPlus tabIndex={0} className='h-5 w-5' role='button' />
</div>
<ul
tabIndex={0}
className='dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow'
>
<li>
<button onClick={onImportBooks}>From Local File</button>
</li>
</ul>
</span>
<button onClick={onToggleSelectMode} aria-label='Select Multiple Books' className='h-6'>
<div className='lg:tooltip lg:tooltip-bottom cursor-pointer' data-tip='Select books'>
<MdSelectAll role='button' className='h-6 w-6' />
</div>
</button>
</div>
</div>
<WindowButtons />
</div>
</div>
);
};
export default LibraryHeader;
@@ -1,42 +0,0 @@
import * as React from 'react';
import { FaSearch, FaPlus } from 'react-icons/fa';
interface SearchBarProps {
onImportBooks: () => void;
}
const SearchBar: React.FC<SearchBarProps> = ({ onImportBooks }) => {
return (
<div className='fixed top-0 z-10 w-full bg-gray-100 p-6'>
<div className='flex items-center justify-between'>
{/* Search Input with Magnifier and Plus Icon */}
<div className='sm:w relative flex w-full items-center'>
{/* Magnifier Icon */}
<span className='absolute left-4 text-gray-500'>
<FaSearch className='w-3' />
</span>
{/* Search Input */}
<input
type='text'
placeholder='Search books...'
className='input input-sm rounded-badge w-full bg-gray-200 pl-10 pr-10 text-base focus:border-none focus:outline-none'
/>
{/* Plus Icon */}
<span className='dropdown dropdown-end absolute right-4 cursor-pointer text-gray-500'>
<FaPlus tabIndex={0} className='w-3' role='button' />
<ul
tabIndex={0}
className='dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow'
>
<li>
<button onClick={onImportBooks}>From Local File</button>
</li>
</ul>
</span>
</div>
</div>
</div>
);
};
export default SearchBar;
+18 -6
View File
@@ -7,7 +7,7 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import Spinner from '@/components/Spinner';
import SearchBar from '@/app/library/components/SearchBar';
import LibraryHeader from '@/app/library/components/Header';
import Bookshelf from '@/app/library/components/Bookshelf';
const LibraryPage = () => {
@@ -15,6 +15,7 @@ const LibraryPage = () => {
const { library: libraryBooks, setLibrary } = useReaderStore();
const [loading, setLoading] = useState(true);
const isInitiating = useRef(false);
const [isSelectMode, setIsSelectMode] = useState(false);
React.useEffect(() => {
if (isInitiating.current) return;
@@ -67,13 +68,24 @@ const LibraryPage = () => {
importBooks(files);
};
const handleToggleSelectMode = () => {
setIsSelectMode(!isSelectMode);
};
return (
<div className='min-h-screen bg-gray-100'>
<SearchBar onImportBooks={handleImportBooks} />
<div className='min-h-screen p-2 pt-16'>
<div className='hero-content'>
<div className='min-h-screen select-none bg-gray-100'>
<LibraryHeader
onImportBooks={handleImportBooks}
onToggleSelectMode={handleToggleSelectMode}
/>
<div className='min-h-screen pt-10'>
<div className='hero-content p-4'>
<Spinner loading={loading} />
<Bookshelf libraryBooks={libraryBooks} onImportBooks={handleImportBooks} />
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
onImportBooks={handleImportBooks}
/>
</div>
{!loading && libraryBooks.length === 0 && (
<div className='hero min-h-screen'>
@@ -52,6 +52,7 @@ export interface FoliateView extends HTMLElement {
goRight: () => void;
renderer: {
setStyles: (css: string) => void;
setAttribute: (name: string, value: string | number) => void;
next: () => Promise<void>;
prev: () => Promise<void>;
};
@@ -84,6 +85,8 @@ const FoliateViewer: React.FC<{
if ('setStyles' in view.renderer) {
view.renderer.setStyles(getCSS(2.4, true, true));
}
view.renderer.setAttribute('margin', '35px');
view.renderer.setAttribute('gap', '4%');
const lastLocation = bookConfig.location;
if (lastLocation) {
view.init({ lastLocation });
@@ -41,7 +41,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
<div
className={clsx(
'footer-bar absolute bottom-0 z-10 flex h-12 w-full items-center px-4',
'shadow-xs bg-gray-100 transition-opacity duration-300',
'shadow-xs bg-base-100 transition-opacity duration-300',
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
)}
@@ -38,7 +38,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<div
className={clsx(
`header-bar absolute top-0 z-10 flex h-10 w-full items-center px-4`,
`shadow-xs bg-gray-100 transition-opacity duration-300`,
`shadow-xs bg-base-100 transition-opacity duration-300`,
isHoveredAnim && 'hover-bar-anim',
hoveredBookKey === bookKey ? `opacity-100` : `opacity-0`,
)}
@@ -46,7 +46,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onMouseLeave={() => setHoveredBookKey('')}
>
<div className='absolute inset-0 flex items-center justify-center'>
<h2 className='line-clamp-1 max-w-[90%] px-2 text-center text-xs font-semibold'>
<h2 className='line-clamp-1 max-w-[80%] px-2 text-center text-xs font-semibold'>
{bookTitle}
</h2>
</div>
@@ -17,7 +17,7 @@ import FooterBar from './FooterBar';
const ReaderContent = () => {
const router = useRouter();
const searchParams = useSearchParams();
const ids = searchParams.getAll('id') || [];
const ids = (searchParams.get('ids') || '').split(',');
const { envConfig } = useEnv();
const { books, settings, initBookState, saveConfig, saveSettings } = useReaderStore();
@@ -151,15 +151,15 @@ const ReaderContent = () => {
setSideBarBookKey={setSideBarBookKey}
setHoveredBookKey={setHoveredBookKey}
/>
<FoliateViewer bookKey={key} bookDoc={bookDoc!} bookConfig={config!} />
<PageInfo bookFormat={book.format} section={section} pageinfo={pageinfo} />
<FooterBar
bookKey={key}
progress={progress}
isHoveredAnim={ids.length > 1}
isHoveredAnim={false}
hoveredBookKey={hoveredBookKey}
setHoveredBookKey={setHoveredBookKey}
/>
<FoliateViewer bookKey={key} bookDoc={bookDoc!} bookConfig={config!} />
<PageInfo bookFormat={book.format} section={section} pageinfo={pageinfo} />;
</div>
);
})}
@@ -82,7 +82,7 @@ const SideBar: React.FC<{
return (
isVisible && (
<div
className='sidebar-container z-20 h-full bg-gray-200'
className='sidebar-container bg-base-200 z-20 h-full select-none'
style={{
width: `${width}`,
minWidth: `${MIN_SIDEBAR_WIDTH * 100}%`,
@@ -94,16 +94,16 @@ const SideBar: React.FC<{
<div className='flex h-10 items-center justify-between pl-1.5 pr-3'>
<div className='flex items-center'>
<button className='btn btn-ghost h-8 min-h-8 w-8 p-0' onClick={onGoToLibrary}>
<GiBookshelf size={20} className='text-gray-600' />
<GiBookshelf size={20} className='fill-base-content' />
</button>
</div>
<div className='flex size-[30%] min-w-20 items-center justify-between'>
<button className='btn btn-ghost left-0 h-8 min-h-8 w-8 p-0'>
<MdSearch size={20} className='text-gray-600' />
<MdSearch size={20} className='fill-base-content' />
</button>
<button
onClick={onTogglePin}
className={`${isPinned ? 'bg-gray-400' : 'bg-gray-300'} btn btn-ghost btn-circle right-0 h-6 min-h-6 w-6`}
className={`${isPinned ? 'bg-gray-300' : 'bg-base-300'} btn btn-ghost btn-circle right-0 h-6 min-h-6 w-6`}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
@@ -119,28 +119,28 @@ const SideBar: React.FC<{
{activeTab === 'bookmarks' && <div>Bookmarks</div>}
{activeTab === 'annotations' && <div>Annotations</div>}
</div>
<div className='bottom-tab absolute bottom-0 flex w-full bg-gray-200'>
<div className='bottom-tab absolute bottom-0 flex w-full'>
<button
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'toc' ? 'bg-gray-300' : ''}`}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'toc' ? 'bg-base-300' : ''}`}
onClick={() => setActiveTab('toc')}
>
<MdToc size={20} className='mx-auto' />
</button>
<button
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'annotations' ? 'bg-gray-300' : ''}`}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'annotations' ? 'bg-base-300' : ''}`}
onClick={() => setActiveTab('annotations')}
>
<MdEditNote size={20} className='mx-auto' />
</button>
<button
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'bookmarks' ? 'bg-gray-300' : ''}`}
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'bookmarks' ? 'bg-base-300' : ''}`}
onClick={() => setActiveTab('bookmarks')}
>
<MdBookmarkBorder size={20} className='mx-auto' />
</button>
</div>
<div
className='drag-bar bg-base-300 absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
className='drag-bar absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
></div>
</div>
+1 -1
View File
@@ -33,7 +33,7 @@ const ReaderPage = () => {
return (
settings.globalReadSettings && (
<div className='min-h-screen bg-gray-100'>
<div className='min-h-screen select-none'>
<Suspense>
<ReaderContent />
</Suspense>
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import clsx from 'clsx';
const Alert: React.FC<{
title: string;
message: string;
onClickCancel: () => void;
onClickConfirm: () => void;
}> = ({ title, message, onClickCancel, onClickConfirm }) => {
return (
<div
role='alert'
className={clsx(
'alert fixed bottom-4 left-1/2 flex -translate-x-1/2 transform items-center justify-between',
'rounded-lg bg-gray-100 p-4 shadow-2xl',
'w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] lg:max-w-[40vw] xl:max-w-[40vw]',
)}
>
<div className='flex items-center space-x-2'>
<svg
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
className='stroke-info h-6 w-6 shrink-0'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
></path>
</svg>
<div className=''>
<h3 className='font-bold'>{title}</h3>
<div className='text-xs'>{message}</div>
</div>
</div>
<div className='flex space-x-2'>
<button className='btn btn-sm' onClick={onClickCancel}>
Cancel
</button>
<button className='btn btn-sm btn-warning' onClick={onClickConfirm}>
Confirm
</button>
</div>
</div>
);
};
export default Alert;
+2 -4
View File
@@ -1,10 +1,8 @@
import React from 'react';
interface SpinnerProps {
const Spinner: React.FC<{
loading: boolean;
}
const Spinner: React.FC<SpinnerProps> = ({ loading }) => {
}> = ({ loading }) => {
if (!loading) return null;
return (
@@ -0,0 +1,79 @@
import React, { useEffect } from 'react';
const WindowButtons: React.FC = () => {
useEffect(() => {
const handleMouseDown = async (e: MouseEvent) => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
if (e.buttons === 1) {
if (e.detail === 2) {
getCurrentWindow().toggleMaximize();
} else {
getCurrentWindow().startDragging();
}
}
};
const header = document.getElementById('titlebar');
header?.addEventListener('mousedown', handleMouseDown);
return () => {
header?.removeEventListener('mousedown', handleMouseDown);
};
}, []);
const handleMinimize = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().minimize();
};
const handleMaximize = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().toggleMaximize();
};
const handleClose = async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().close();
};
return (
<div className='window-buttons ml-6 flex h-8 items-center justify-end space-x-2'>
<button
onClick={handleMinimize}
className='window-button'
aria-label='Minimize'
id='titlebar-minimize'
>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path fill='currentColor' d='M20 14H4v-2h16' />
</svg>
</button>
<button
onClick={handleMaximize}
className='window-button'
aria-label='Maximize/Restore'
id='titlebar-maximize'
>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path fill='currentColor' d='M4 4h16v16H4zm2 4v10h12V8z' />
</svg>
</button>
<button
onClick={handleClose}
className='window-button'
aria-label='Close'
id='titlebar-close'
>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path
fill='currentColor'
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
/>
</svg>
</button>
</div>
);
};
export default WindowButtons;
@@ -24,6 +24,19 @@ import { LOCAL_BOOKS_SUBDIR } from './constants';
export const isMobile = ['android', 'ios'].includes(osType());
import { getCurrentWindow } from '@tauri-apps/api/window';
export const initTitlebar = () => {
const appWindow = getCurrentWindow();
document
.getElementById('titlebar-minimize')!
.addEventListener('click', () => appWindow.minimize());
document
.getElementById('titlebar-maximize')!
.addEventListener('click', () => appWindow.toggleMaximize());
document.getElementById('titlebar-close')!.addEventListener('click', () => appWindow.close());
};
export const resolvePath = (
fp: string,
base: BaseDir,
+11
View File
@@ -36,6 +36,7 @@ interface ReaderStore {
setFoliateView: (key: string, view: FoliateView) => void;
getFoliateView: (key: string) => FoliateView | null;
deleteBook: (envConfig: EnvConfigType, book: Book) => void;
saveConfig: (envConfig: EnvConfigType, book: Book, config: BookConfig) => void;
saveSettings: (envConfig: EnvConfigType, settings: SystemSettings) => void;
@@ -68,6 +69,16 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
getFoliateView: (key: string) => get().foliateViews[key] || null,
deleteBook: async (envConfig: EnvConfigType, book: Book) => {
const appService = await envConfig.getAppService();
const { library } = get();
const bookIndex = library.findIndex((b) => b.hash === book.hash);
if (bookIndex !== -1) {
library.splice(bookIndex, 1);
}
set({ library });
appService.saveLibraryBooks(library);
},
saveConfig: async (envConfig: EnvConfigType, book: Book, config: BookConfig) => {
const appService = await envConfig.getAppService();
const { library } = get();