From 1bca62b738cc43fe57ac40c2154382ffea443b4b Mon Sep 17 00:00:00 2001 From: chrox Date: Mon, 21 Oct 2024 20:55:34 +0200 Subject: [PATCH] Add sidebar in reader page --- .../src/app/reader/components/BookCard.tsx | 29 ++++ .../app/reader/components/ReaderContent.tsx | 100 +++++++++--- .../src/app/reader/components/SideBar.tsx | 150 ++++++++++++++++++ apps/readest-app/src/app/reader/page.tsx | 49 +++--- apps/readest-app/src/components/NavBar.tsx | 25 --- apps/readest-app/src/context/EnvContext.tsx | 2 +- apps/readest-app/src/services/appService.ts | 9 +- apps/readest-app/src/services/constants.ts | 13 ++ apps/readest-app/src/services/environment.ts | 4 +- apps/readest-app/src/store/readerStore.ts | 16 +- apps/readest-app/src/types/settings.ts | 3 + apps/readest-app/src/utils/book.ts | 26 +++ 12 files changed, 337 insertions(+), 89 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/BookCard.tsx create mode 100644 apps/readest-app/src/app/reader/components/SideBar.tsx delete mode 100644 apps/readest-app/src/components/NavBar.tsx diff --git a/apps/readest-app/src/app/reader/components/BookCard.tsx b/apps/readest-app/src/app/reader/components/BookCard.tsx new file mode 100644 index 00000000..ef36b8fa --- /dev/null +++ b/apps/readest-app/src/app/reader/components/BookCard.tsx @@ -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 = ({ cover, title, author }) => { + return ( +
+ Book cover +
+

{title}

+

{formatAuthors(author)}

+
+ +
+ ); +}; + +export default BookCard; diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx index 6d19c23b..3fa6df95 100644 --- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx +++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx @@ -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 = ({ isClosingBook }) => { +const ReaderContent: React.FC = ({}) => { + const router = useRouter(); const searchParams = useSearchParams(); const id = searchParams.get('id'); const [bookDoc, setBookDoc] = React.useState(); 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 = ({ 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 = ({ 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 ( -
- {bookState.loading && } - {bookState.error && ( -
-

{bookState.error}

+
+ setSideBarVisibility(visibility)} + /> + +
+ {bookState.loading && } + {bookState.error && ( +
+

{bookState.error}

+
+ )} +
setTopBarVisibility(true)} + onMouseLeave={() => setTopBarVisibility(false)} + > +
+ +
+
+

{bookState.book.title}

+
- )} - + +
); }; diff --git a/apps/readest-app/src/app/reader/components/SideBar.tsx b/apps/readest-app/src/app/reader/components/SideBar.tsx new file mode 100644 index 00000000..7e8c7392 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/SideBar.tsx @@ -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 = ({ + book, + width, + isPinned, + isVisible, + onTogglePin, + onSetVisibility, + onResize, + onGoToLibrary, +}) => { + const [activeTab, setActiveTab] = useState('toc'); + const sidebarRef = useRef(null); + + useEffect(() => {}, [isPinned, isVisible]); + + const handleClickOverlay = () => { + onSetVisibility(false); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + 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 && ( +
+
+
+
+ +
+
+ + +
+
+
+ +
+
+ + + +
+
+ {activeTab === 'toc' &&
Table of Contents
} + {activeTab === 'bookmarks' &&
Bookmarks
} + {activeTab === 'annotations' &&
Annotations
} +
+
+
+ {!isPinned && ( +
handleClickOverlay()} + /> + )} +
+ ) + ); +}; + +export default SideBar; diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx index a92989df..e1307cd7 100644 --- a/apps/readest-app/src/app/reader/page.tsx +++ b/apps/readest-app/src/app/reader/page.tsx @@ -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 ( -
-
- - - - -
+ settings.globalReadSettings && ( +
+ + + +
+ ) ); }; diff --git a/apps/readest-app/src/components/NavBar.tsx b/apps/readest-app/src/components/NavBar.tsx deleted file mode 100644 index 50b76220..00000000 --- a/apps/readest-app/src/components/NavBar.tsx +++ /dev/null @@ -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 = ({ onBack, isVisible }) => { - return ( - isVisible && ( -
-
- - - -
-
- ) - ); -}; - -export default NavBar; diff --git a/apps/readest-app/src/context/EnvContext.tsx b/apps/readest-app/src/context/EnvContext.tsx index 6ce4b57c..174aaf82 100644 --- a/apps/readest-app/src/context/EnvContext.tsx +++ b/apps/readest-app/src/context/EnvContext.tsx @@ -18,7 +18,7 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => { const [appService, setAppService] = useState(null); const getAppService = async (envConfig: EnvConfigType): Promise => { - const service = await envConfig.initAppService(); + const service = await envConfig.getAppService(); setAppService(service); return service; }; diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index b06cf2e6..2656439e 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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); diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 87ed1a26..cea0cb2a 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -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, +}; diff --git a/apps/readest-app/src/services/environment.ts b/apps/readest-app/src/services/environment.ts index 6ba138ae..ec902c2c 100644 --- a/apps/readest-app/src/services/environment.ts +++ b/apps/readest-app/src/services/environment.ts @@ -3,11 +3,11 @@ import { AppService } from '@/types/system'; let appService: AppService | null = null; export interface EnvConfigType { - initAppService: () => Promise; + getAppService: () => Promise; } const environmentConfig: EnvConfigType = { - initAppService: async () => { + getAppService: async () => { if (!appService) { const { NativeAppService } = await import('@/services/nativeAppService'); appService = new NativeAppService(); diff --git a/apps/readest-app/src/store/readerStore.ts b/apps/readest-app/src/store/readerStore.ts index 62770188..a9aa08ea 100644 --- a/apps/readest-app/src/store/readerStore.ts +++ b/apps/readest-app/src/store/readerStore.ts @@ -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; + settings: SystemSettings; setLibrary: (books: Book[]) => void; - fetchBook: (appService: AppService, id: string) => Promise; + setSettings: (settings: SystemSettings) => void; + fetchBook: (envConfig: EnvConfigType, id: string) => Promise; 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((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((set) => ({ })); try { + const appService = await envConfig.getAppService(); const library = await appService.loadLibraryBooks(); const book = library.find((b) => b.hash === id); if (!book) { diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 8909705c..4a99c0b6 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -6,6 +6,9 @@ export interface ReadSettings { fontSize: number; wordSpacing: number; lineSpacing: number; + + sideBarWidth: string; + isSideBarPinned: boolean; } export interface SystemSettings { diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index 35622d31..c2b57121 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -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);