Add sidebar in reader page

This commit is contained in:
chrox
2024-10-21 20:55:34 +02:00
parent cacbe774b1
commit 1bca62b738
12 changed files with 337 additions and 89 deletions
@@ -0,0 +1,29 @@
import React from 'react';
import { MdInfoOutline } from 'react-icons/md';
import { formatAuthors } from '@/utils/book';
interface BookCardProps {
cover: string;
title: string;
author: string;
}
const BookCard: React.FC<BookCardProps> = ({ cover, title, author }) => {
return (
<div className='flex w-full items-center'>
<img src={cover} alt='Book cover' className='mr-4 w-[15%] rounded-sm object-cover' />
<div className='min-w-0 flex-1'>
<h4 className='w-[90%] truncate text-base font-semibold'>{title}</h4>
<p className='truncate text-sm text-gray-600'>{formatAuthors(author)}</p>
</div>
<button
className='btn btn-ghost h-6 min-h-6 w-6 rounded-full p-0 transition-colors hover:bg-gray-300'
aria-label='More info'
>
<MdInfoOutline size={18} className='text-gray-600' />
</button>
</div>
);
};
export default BookCard;
@@ -1,7 +1,9 @@
'use client';
import React from 'react';
import { useSearchParams } from 'next/navigation';
import * as React from 'react';
import { useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { BsLayoutSidebar } from 'react-icons/bs';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
@@ -9,21 +11,31 @@ import { BookDoc, DocumentLoader } from '@/libs/document';
import Spinner from '@/components/Spinner';
import FoliateViewer from './FoliateViewer';
import SideBar from './SideBar';
interface ReaderContentProps {
isClosingBook: boolean;
}
interface ReaderContentProps {}
const ReaderContent: React.FC<ReaderContentProps> = ({ isClosingBook }) => {
const ReaderContent: React.FC<ReaderContentProps> = ({}) => {
const router = useRouter();
const searchParams = useSearchParams();
const id = searchParams.get('id');
const [bookDoc, setBookDoc] = React.useState<BookDoc>();
const { envConfig } = useEnv();
const { library, books, fetchBook, setLibrary } = useReaderStore();
const { library, books, settings, fetchBook, setLibrary } = useReaderStore();
const defaultBookState = { loading: true, error: null, file: null, book: null, config: null };
const bookState = id ? books[id] || defaultBookState : defaultBookState;
const [sideBarWidth, setSideBarWidth] = useState(
settings.globalReadSettings.sideBarWidth ?? '20%',
);
const [isSideBarPinned, setIsSideBarPinned] = useState(
settings.globalReadSettings.isSideBarPinned ?? true,
);
const [isSideBarVisible, setSideBarVisibility] = useState(isSideBarPinned);
const [isClosingBook, setClosingBook] = useState(false);
const [isTopBarVisible, setTopBarVisibility] = useState(false);
React.useEffect(() => {
if (!id) {
return;
@@ -37,18 +49,17 @@ const ReaderContent: React.FC<ReaderContentProps> = ({ isClosingBook }) => {
library[bookIndex] = book;
}
setLibrary(library);
envConfig.initAppService().then((appService) => {
envConfig.getAppService().then((appService) => {
config.lastUpdated = Date.now();
appService.saveBookConfig(book, config);
appService.saveLibraryBooks(library);
appService.saveSettings(settings);
});
}
return;
}
if (id && !file) {
envConfig.initAppService().then((appService) => {
fetchBook(appService, id);
});
fetchBook(envConfig, id);
}
if (!bookDoc && bookState.file) {
const loadDocument = async () => {
@@ -59,22 +70,71 @@ const ReaderContent: React.FC<ReaderContentProps> = ({ isClosingBook }) => {
};
loadDocument();
}
return;
}, [isClosingBook, bookState.file, envConfig, fetchBook, id]);
if (!id || !bookDoc || !bookState.config) {
const handleResize = (newWidth: string) => {
setSideBarWidth(newWidth);
settings.globalReadSettings.sideBarWidth = newWidth;
};
const handleTogglePin = () => {
if (isSideBarPinned && isSideBarVisible) {
setSideBarVisibility(false);
}
setIsSideBarPinned(!isSideBarPinned);
settings.globalReadSettings.isSideBarPinned = !isSideBarPinned;
};
const handleCloseBook = () => {
setClosingBook(true);
router.back();
};
const topBarWidth = isSideBarPinned && isSideBarVisible ? `calc(100% - ${sideBarWidth})` : '100%';
if (!id || !bookDoc || !bookState.config || !bookState.book) {
return null;
}
return (
<div>
{bookState.loading && <Spinner loading={bookState.loading} />}
{bookState.error && (
<div className='text-center'>
<h2 className='text-red-500'>{bookState.error}</h2>
<div className='flex h-screen overflow-hidden'>
<SideBar
book={bookState.book}
width={sideBarWidth}
isVisible={isSideBarVisible}
isPinned={isSideBarPinned}
onResize={handleResize}
onTogglePin={handleTogglePin}
onGoToLibrary={handleCloseBook}
onSetVisibility={(visibility: boolean) => setSideBarVisibility(visibility)}
/>
<div className={`flex-1`}>
{bookState.loading && <Spinner loading={bookState.loading} />}
{bookState.error && (
<div className='text-center'>
<h2 className='text-red-500'>{bookState.error}</h2>
</div>
)}
<div
className={`topbar absolute top-0 z-10 h-10 border-b ${
isTopBarVisible ? 'opacity-100' : 'opacity-0'
} flex items-center px-4`}
style={{ width: topBarWidth }}
onMouseEnter={() => setTopBarVisibility(true)}
onMouseLeave={() => setTopBarVisibility(false)}
>
<div className='absolute left-4 flex h-full items-center p-2'>
<button onClick={() => setSideBarVisibility(!isSideBarVisible)}>
<BsLayoutSidebar size={16} />
</button>
</div>
<div className='absolute left-1/2 -translate-x-1/2 transform'>
<h2 className='text-center text-sm font-semibold'>{bookState.book.title}</h2>
</div>
</div>
)}
<FoliateViewer bookId={id} bookDoc={bookDoc} bookConfig={bookState.config} />
<FoliateViewer bookId={id} bookDoc={bookDoc} bookConfig={bookState.config} />
</div>
</div>
);
};
@@ -0,0 +1,150 @@
import { Book } from '@/types/book';
import React, { useState, useRef, useEffect } from 'react';
import {
MdSearch,
MdOutlinePushPin,
MdPushPin,
MdToc,
MdEditNote,
MdBookmarkBorder,
} from 'react-icons/md';
import { GiBookshelf } from 'react-icons/gi';
import BookCard from './BookCard';
interface SideBarProps {
book: Book;
width: string;
isVisible: boolean;
isPinned: boolean;
onSetVisibility: (visibility: boolean) => void;
onTogglePin: () => void;
onResize: (newWidth: string) => void;
onGoToLibrary: () => void;
}
const MIN_SIDEBAR_WIDTH = '10em';
const MAX_SIDEBAR_WIDTH = '40em';
const SideBar: React.FC<SideBarProps> = ({
book,
width,
isPinned,
isVisible,
onTogglePin,
onSetVisibility,
onResize,
onGoToLibrary,
}) => {
const [activeTab, setActiveTab] = useState('toc');
const sidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {}, [isPinned, isVisible]);
const handleClickOverlay = () => {
onSetVisibility(false);
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
const newWidthPx = e.clientX;
const width = `${Math.round((newWidthPx / window.innerWidth) * 10000) / 100}%`;
const minWidthPx = parseFloat(MIN_SIDEBAR_WIDTH) * 16;
const maxWidthPx = parseFloat(MAX_SIDEBAR_WIDTH) * 16;
if (newWidthPx >= minWidthPx && newWidthPx <= maxWidthPx) {
onResize(width);
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
return (
isVisible && (
<div
className='sidebar-container z-20 h-full bg-gray-200'
style={{
width: `${width}`,
height: '100%',
minWidth: MIN_SIDEBAR_WIDTH,
maxWidth: MAX_SIDEBAR_WIDTH,
position: isPinned ? 'relative' : 'absolute',
}}
>
<div ref={sidebarRef} className={'sidebar h-full'}>
<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' />
</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' />
</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`}
>
{isPinned ? <MdPushPin size={14} /> : <MdOutlinePushPin size={14} />}
</button>
</div>
</div>
<div className='border-b p-3 shadow-sm'>
<BookCard cover={book.coverImageUrl!} title={book.title} author={book.author} />
</div>
<div className='absolute bottom-0 flex w-full'>
<button
className={`m-1.5 flex-1 rounded-md p-2 ${activeTab === 'toc' ? 'bg-gray-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' : ''}`}
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' : ''}`}
onClick={() => setActiveTab('bookmarks')}
>
<MdBookmarkBorder size={20} className='mx-auto' />
</button>
</div>
<div className='p-4'>
{activeTab === 'toc' && <div>Table of Contents</div>}
{activeTab === 'bookmarks' && <div>Bookmarks</div>}
{activeTab === 'annotations' && <div>Annotations</div>}
</div>
<div
className='drag-bar bg-base-300 absolute right-0 top-0 h-full w-0.5 cursor-col-resize'
onMouseDown={handleMouseDown}
></div>
</div>
{!isPinned && (
<div
className='overlay fixed top-0'
style={{
left: width,
width: `calc(100% - ${width})`,
height: '100%',
background: 'rgba(0, 0, 0, 0.2)',
}}
onClick={() => handleClickOverlay()}
/>
)}
</div>
)
);
};
export default SideBar;
+18 -31
View File
@@ -1,53 +1,40 @@
'use client';
import * as React from 'react';
import { useState, useEffect, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useEffect, Suspense } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import NavBar from '@/components/NavBar';
import ReaderContent from './components/ReaderContent';
import { DEFAULT_READSETTINGS } from '@/services/constants';
const ReaderPage = () => {
const router = useRouter();
const [isNavBarVisible, setIsNavBarVisible] = useState(false);
const [isClosingBook, setIsClosingBook] = useState(false);
const { envConfig } = useEnv();
const { setLibrary } = useReaderStore();
const handleBack = () => {
console.log('Back to bookshelf');
setIsClosingBook(true);
router.back();
};
const handleTap = () => {
setIsNavBarVisible((pre) => !pre);
};
const { settings, setLibrary, setSettings } = useReaderStore();
useEffect(() => {
const fetchLibrary = async () => {
const appService = await envConfig.initAppService();
const initLibrary = async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
if (!settings.globalReadSettings) {
settings.globalReadSettings = DEFAULT_READSETTINGS;
}
setSettings(settings);
setLibrary(await appService.loadLibraryBooks());
};
fetchLibrary();
initLibrary();
}, [envConfig, setLibrary]);
return (
<div className='min-h-screen bg-gray-100'>
<div
className={`absolute inset-0 z-20 ${isNavBarVisible ? 'mt-10' : 'ml-20 h-20'}`}
onClick={handleTap}
/>
<NavBar onBack={handleBack} isVisible={isNavBarVisible} />
<Suspense>
<ReaderContent isClosingBook={isClosingBook} />
</Suspense>
</div>
settings.globalReadSettings && (
<div className='min-h-screen bg-gray-100'>
<Suspense>
<ReaderContent />
</Suspense>
</div>
)
);
};
@@ -1,25 +0,0 @@
'use client';
import React from 'react';
import { FaChevronLeft } from 'react-icons/fa';
interface NavBarProps {
onBack: () => void;
isVisible: boolean;
}
const NavBar: React.FC<NavBarProps> = ({ onBack, isVisible }) => {
return (
isVisible && (
<div className='fixed left-0 right-0 top-0 z-10 bg-gray-50 p-6'>
<div className='flex items-center justify-between'>
<span className='absolute left-2 text-gray-500' onClick={onBack}>
<FaChevronLeft className='w-10' />
</span>
</div>
</div>
)
);
};
export default NavBar;
+1 -1
View File
@@ -18,7 +18,7 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
const [appService, setAppService] = useState<AppService | null>(null);
const getAppService = async (envConfig: EnvConfigType): Promise<AppService> => {
const service = await envConfig.initAppService();
const service = await envConfig.getAppService();
setAppService(service);
return service;
};
+2 -7
View File
@@ -15,6 +15,7 @@ import {
import { RemoteFile } from '@/utils/file';
import { partialMD5 } from '@/utils/md5';
import { BookDoc, DocumentLoader } from '@/libs/document';
import { DEFAULT_READSETTINGS } from './constants';
export abstract class BaseAppService implements AppService {
localBooksDir: string = '';
@@ -47,13 +48,7 @@ export abstract class BaseAppService implements AppService {
} catch {
settings = {
localBooksDir: await this.getInitBooksDir(),
globalReadSettings: {
themeType: 'auto',
fontFamily: '',
fontSize: 1.0,
wordSpacing: 0.16,
lineSpacing: 1.5,
},
globalReadSettings: DEFAULT_READSETTINGS,
};
await this.fs.createDir('', 'Books', true);
@@ -1,2 +1,15 @@
import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
export const DEFAULT_READSETTINGS: ReadSettings = {
themeType: 'auto',
fontFamily: '',
fontSize: 1.0,
wordSpacing: 0.16,
lineSpacing: 1.5,
sideBarWidth: '20%',
isSideBarPinned: false,
};
+2 -2
View File
@@ -3,11 +3,11 @@ import { AppService } from '@/types/system';
let appService: AppService | null = null;
export interface EnvConfigType {
initAppService: () => Promise<AppService>;
getAppService: () => Promise<AppService>;
}
const environmentConfig: EnvConfigType = {
initAppService: async () => {
getAppService: async () => {
if (!appService) {
const { NativeAppService } = await import('@/services/nativeAppService');
appService = new NativeAppService();
+13 -3
View File
@@ -1,7 +1,8 @@
import { create } from 'zustand';
import { BookNote, BookContent, Book, BookConfig, PageInfo } from '@/types/book';
import { AppService } from '@/types/system';
import { EnvConfigType } from '@/services/environment';
import { SystemSettings } from '@/types/settings';
interface BookState {
loading?: boolean;
@@ -15,9 +16,11 @@ interface BookState {
interface ReaderStore {
library: Book[];
books: Record<string, BookState>;
settings: SystemSettings;
setLibrary: (books: Book[]) => void;
fetchBook: (appService: AppService, id: string) => Promise<Book | null>;
setSettings: (settings: SystemSettings) => void;
fetchBook: (envConfig: EnvConfigType, id: string) => Promise<Book | null>;
setProgress: (id: string, progress: number, location: string, pageinfo: PageInfo) => void;
addBookmark: (id: string, bookmark: BookNote) => void;
}
@@ -25,10 +28,16 @@ interface ReaderStore {
export const useReaderStore = create<ReaderStore>((set) => ({
library: [],
books: {},
settings: {} as SystemSettings,
setLibrary: (books: Book[]) => set({ library: books }),
setSettings: (settings: SystemSettings) => set({ settings }),
saveSettings: async (envConfig: EnvConfigType, settings: SystemSettings) => {
const appService = await envConfig.getAppService();
await appService.saveSettings(settings);
},
fetchBook: async (appService: AppService, id: string) => {
fetchBook: async (envConfig: EnvConfigType, id: string) => {
set((state) => ({
books: {
...state.books,
@@ -37,6 +46,7 @@ export const useReaderStore = create<ReaderStore>((set) => ({
}));
try {
const appService = await envConfig.getAppService();
const library = await appService.loadLibraryBooks();
const book = library.find((b) => b.hash === id);
if (!book) {
+3
View File
@@ -6,6 +6,9 @@ export interface ReadSettings {
fontSize: number;
wordSpacing: number;
lineSpacing: number;
sideBarWidth: string;
isSideBarPinned: boolean;
}
export interface SystemSettings {
+26
View File
@@ -27,3 +27,29 @@ export const getBaseFilename = (filename: string) => {
export const INIT_BOOK_CONFIG: BookConfig = {
lastUpdated: 0,
};
interface LanguageMap {
[key: string]: string;
}
const formatLanguageMap = (x: string | LanguageMap): string => {
if (!x) return '';
if (typeof x === 'string') return x;
const keys = Object.keys(x);
return x[keys[0]!]!;
};
const listFormat = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
const formatContributors = (contributors: any) =>
Array.isArray(contributors)
? listFormat.format(
contributors.map((contributor) =>
typeof contributor === 'string' ? contributor : formatLanguageMap(contributor?.name),
),
)
: typeof contributors === 'string'
? contributors
: formatLanguageMap(contributors?.name);
export const formatAuthors = (authors: any) => formatContributors(authors);