Add information modal for book details (#27)
* Add BookDetailModal component and implement details view in Bookshelf * Enhance BookDetailModal to fetch and display detailed book metadata * Refactor Bookshelf and BookDetailModal for improved click handling and styling consistency * Enhance BookDetailModal with additional metadata fields and improved layout * Refactor BookDetailModal to use appService for fetching book details and remove deprecated bookService
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { PiPlus } from 'react-icons/pi';
|
||||
import { MdDelete, MdOpenInNew } from 'react-icons/md';
|
||||
import { MdCheckCircle, MdCheckCircleOutline } from 'react-icons/md';
|
||||
import { CiCircleMore } from "react-icons/ci";
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
@@ -22,6 +23,7 @@ import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
import Alert from '@/components/Alert';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import BookDetailModal from '@/components/BookDetailModal';
|
||||
|
||||
type BookshelfItem = Book | BooksGroup;
|
||||
|
||||
@@ -67,6 +69,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
const [clickedImage, setClickedImage] = useState<string | null>(null);
|
||||
const [importBookUrl] = useState(searchParams?.get('url') || '');
|
||||
const isImportingBook = useRef(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||
|
||||
const showMoreDetails = (book: Book) => {
|
||||
setIsModalOpen(true);
|
||||
setSelectedBook(book);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const { setLibrary } = useLibraryStore();
|
||||
|
||||
@@ -171,9 +184,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
<div
|
||||
className='book-item cursor-pointer'
|
||||
onContextMenu={bookContextMenuHandler.bind(null, item as Book)}
|
||||
onClick={() => handleBookClick(item.hash)}
|
||||
>
|
||||
<div key={(item as Book).hash} className='bg-base-100 shadow-md'>
|
||||
<div key={(item as Book).hash}
|
||||
className='bg-base-100 shadow-md'
|
||||
onClick={() => handleBookClick(item.hash)}
|
||||
>
|
||||
<div className='relative aspect-[28/41]'>
|
||||
<Image
|
||||
src={item.coverImageUrl!}
|
||||
@@ -212,10 +227,17 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='card-body p-0 pt-2'>
|
||||
<div className='card-body flex flex-row items-center justify-between p-0 pt-2'>
|
||||
<h4 className='card-title line-clamp-1 text-[0.6em] text-xs font-semibold'>
|
||||
{(item as Book).title}
|
||||
</h4>
|
||||
<div
|
||||
className='card-detail'
|
||||
role='button'
|
||||
onClick={showMoreDetails.bind(null, item as Book)}
|
||||
>
|
||||
<CiCircleMore size={15} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -275,6 +297,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
|
||||
onClickConfirm={confirmDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal Component */}
|
||||
{selectedBook && (
|
||||
<BookDetailModal isOpen={isModalOpen} onClose={closeModal} book={selectedBook} envConfig={envConfig} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
|
||||
const BookDetailModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
book,
|
||||
envConfig,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
book: Book;
|
||||
envConfig: EnvConfigType;
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
const [bookMeta, setBookMeta] = useState<null | {
|
||||
title: string;
|
||||
language: string | string[];
|
||||
editor?: string;
|
||||
publisher?: string;
|
||||
published?: string;
|
||||
description?: string;
|
||||
subject?: string[];
|
||||
identifier?: string;
|
||||
}>(null);
|
||||
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBookDetails = async () => {
|
||||
const appService = await envConfig.getAppService();
|
||||
const details = await appService.fetchBookDetails(book, settings);
|
||||
setBookMeta(details);
|
||||
};
|
||||
fetchBookDetails();
|
||||
}, [book]);
|
||||
|
||||
if (!bookMeta)
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<div className='fixed inset-0 bg-gray-800 bg-opacity-70' onClick={onClose} />
|
||||
|
||||
<div className='bg-base-200 relative z-50 w-full max-w-md rounded-lg p-6 shadow-xl'>
|
||||
<div className='absolute right-4 top-4 flex space-x-2'>
|
||||
<WindowButtons
|
||||
className='window-buttons flex'
|
||||
showMinimize={false}
|
||||
showMaximize={false}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
<h2 className='text-base-content text-center text-2xl font-semibold'>
|
||||
Loading Book Details...
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<div className='fixed inset-0 bg-gray-800 bg-opacity-70' onClick={onClose} />
|
||||
|
||||
<div className='bg-base-200 relative z-50 w-full max-w-md rounded-lg p-6 shadow-xl'>
|
||||
<div className='absolute right-4 top-4 flex space-x-2'>
|
||||
<WindowButtons
|
||||
className='window-buttons flex'
|
||||
showMinimize={false}
|
||||
showMaximize={false}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-6 flex items-start'>
|
||||
{book.coverImageUrl ? (
|
||||
<img
|
||||
src={book.coverImageUrl}
|
||||
alt={book.title}
|
||||
className='mr-4 h-40 w-30 rounded-lg object-contain shadow-md'
|
||||
/>
|
||||
) : (
|
||||
<div className='mr-4 flex h-40 w-30 items-center justify-center rounded-lg bg-gray-300'>
|
||||
<span className='text-gray-500'>No Image</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='h-40 flex flex-col justify-between'>
|
||||
<h2 className='text-base-content mb-2 text-2xl font-bold'>
|
||||
{bookMeta.title || 'Untitled'}
|
||||
</h2>
|
||||
<p className='text-neutral-content mb-4'>{book.author || 'Unknown Author'}</p>
|
||||
<button className='mt-4 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600'>
|
||||
More Info
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-base-content mb-4'>
|
||||
<div className='mb-4 grid grid-cols-3 gap-4'>
|
||||
<div>
|
||||
<span className='font-bold'>Publisher:</span>
|
||||
<p className='text-neutral-content'>{bookMeta.publisher || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-bold'>Published:</span>
|
||||
<p className='text-neutral-content'>{bookMeta.published || 'Unknown Date'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-bold'>Updated:</span>
|
||||
<p className='text-neutral-content'>
|
||||
{book.lastUpdated
|
||||
? new Date(book.lastUpdated).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: 'Unknown Date'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<div>
|
||||
<span className='font-bold'>Language:</span>
|
||||
<p className='text-neutral-content'>{bookMeta.language || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-bold'>Identifier:</span>
|
||||
<p className='text-neutral-content'>
|
||||
{bookMeta.identifier ? bookMeta.identifier.slice(-8) : 'N/A'}
|
||||
</p>{' '}
|
||||
{/* Show last 8 characters */}
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-bold'>Subjects:</span>
|
||||
<p className='text-neutral-content'>{bookMeta.subject?.join(', ') || 'None'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookDetailModal;
|
||||
@@ -219,6 +219,12 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchBookDetails(book: Book, settings: SystemSettings) {
|
||||
const { file } = await this.loadBookContent(book, settings) as BookContent;
|
||||
const bookDoc = (await new DocumentLoader(file).open()).book as BookDoc;
|
||||
return bookDoc.metadata;
|
||||
}
|
||||
|
||||
async saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings) {
|
||||
let serializedConfig: string;
|
||||
if (settings) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemSettings } from './settings';
|
||||
import { Book, BookConfig, BookContent } from './book';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
|
||||
export type AppPlatform = 'web' | 'tauri';
|
||||
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None';
|
||||
@@ -38,6 +39,7 @@ export interface AppService {
|
||||
): Promise<Book | null>;
|
||||
deleteBook(book: Book): Promise<void>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
fetchBookDetails(book: Book, settings: SystemSettings): Promise<BookDoc['metadata']>;
|
||||
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
|
||||
loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent>;
|
||||
loadLibraryBooks(): Promise<Book[]>;
|
||||
|
||||
Reference in New Issue
Block a user