forked from akai/readest
Save reading progress in book config
This commit is contained in:
@@ -3,46 +3,35 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
import { Book } from '@/types/book';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
|
||||
import SearchBar from '@/components/SearchBar';
|
||||
import Spinner from '@/components/Spinner';
|
||||
import Bookshelf from '@/components/Bookshelf';
|
||||
|
||||
const LibraryPage = () => {
|
||||
const { envConfig, getAppService, appService } = useEnv();
|
||||
const [libraryBooks, setLibraryBooks] = useState<Book[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const { envConfig, appService, getAppService } = useEnv();
|
||||
const { library: libraryBooks, setLibrary } = useReaderStore();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isInitiating = useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInitiating.current) return;
|
||||
isInitiating.current = true;
|
||||
setLoading(true);
|
||||
getAppService(envConfig).then((appService) => {
|
||||
appService.loadSettings().then((settings) => {
|
||||
console.log('Settings', settings);
|
||||
appService
|
||||
.loadLibraryBooks()
|
||||
.then((libraryBooks) => {
|
||||
setLibraryBooks(libraryBooks);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
appService.showMessage(`Failed to load library books: ${err}`, 'error');
|
||||
});
|
||||
});
|
||||
getAppService(envConfig).then(async (appService) => {
|
||||
console.log('Loading library books...');
|
||||
setLibrary(await appService.loadLibraryBooks());
|
||||
setLoading(false);
|
||||
});
|
||||
}, [envConfig, getAppService]);
|
||||
}, [envConfig, libraryBooks, setLibrary]);
|
||||
|
||||
const importBooks = async (files: [string | File]) => {
|
||||
setLoading(true);
|
||||
for (const file of files) {
|
||||
await appService?.importBook(file, libraryBooks);
|
||||
setLibraryBooks(libraryBooks);
|
||||
setLibrary(libraryBooks);
|
||||
}
|
||||
appService?.saveLibraryBooks(libraryBooks);
|
||||
setLoading(false);
|
||||
|
||||
+25
-13
@@ -1,10 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { BookDoc } from '@/libs/document';
|
||||
import { BookConfig } from '@/types/book';
|
||||
|
||||
type FoliateViewerProps = {
|
||||
book: BookDoc;
|
||||
bookId: string;
|
||||
bookConfig: BookConfig;
|
||||
bookDoc: BookDoc;
|
||||
};
|
||||
|
||||
const getCSS = (spacing: number, justify: boolean, hyphenate: boolean) => `
|
||||
@@ -46,8 +50,10 @@ const getCSS = (spacing: number, justify: boolean, hyphenate: boolean) => `
|
||||
}
|
||||
`;
|
||||
|
||||
interface FoliateView extends HTMLElement {
|
||||
export interface FoliateView extends HTMLElement {
|
||||
open: (book: BookDoc) => Promise<void>;
|
||||
init: (options: { lastLocation: string }) => void;
|
||||
goToFraction: (fraction: number) => void;
|
||||
renderer: {
|
||||
setStyles: (css: string) => void;
|
||||
next: () => Promise<void>;
|
||||
@@ -55,8 +61,9 @@ interface FoliateView extends HTMLElement {
|
||||
};
|
||||
}
|
||||
|
||||
const FoliateViewer: React.FC<FoliateViewerProps> = ({ book }) => {
|
||||
const FoliateViewer: React.FC<FoliateViewerProps> = ({ bookId, bookConfig, bookDoc }) => {
|
||||
const viewRef = useRef<HTMLDivElement>(null);
|
||||
const [view, setView] = useState<FoliateView | null>(null);
|
||||
const isViewCreated = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,18 +73,24 @@ const FoliateViewer: React.FC<FoliateViewerProps> = ({ book }) => {
|
||||
const view = document.createElement('foliate-view') as FoliateView;
|
||||
document.body.append(view);
|
||||
viewRef.current?.appendChild(view);
|
||||
|
||||
console.log('Open the book with foliate-view:', book);
|
||||
await view.open(book);
|
||||
setView(view);
|
||||
await view.open(bookDoc);
|
||||
if ('setStyles' in view.renderer) {
|
||||
view.renderer.setStyles(getCSS(1.4, true, true));
|
||||
view.renderer.setStyles(getCSS(2.4, true, true));
|
||||
}
|
||||
const lastLocation = bookConfig.location;
|
||||
if (lastLocation) {
|
||||
view.init({ lastLocation });
|
||||
} else {
|
||||
view.goToFraction(0);
|
||||
}
|
||||
await view.renderer.next();
|
||||
};
|
||||
|
||||
openBook();
|
||||
isViewCreated.current = true;
|
||||
}, [book]);
|
||||
}, [bookDoc]);
|
||||
|
||||
useFoliateEvents(view, bookId);
|
||||
|
||||
const handleTap = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const { clientX } = event;
|
||||
@@ -85,11 +98,10 @@ const FoliateViewer: React.FC<FoliateViewerProps> = ({ book }) => {
|
||||
const leftThreshold = width * 0.5;
|
||||
const rightThreshold = width * 0.5;
|
||||
|
||||
const existingView = viewRef.current?.querySelector('foliate-view') as FoliateView;
|
||||
if (clientX < leftThreshold) {
|
||||
existingView?.renderer?.prev();
|
||||
view?.renderer.prev();
|
||||
} else if (clientX > rightThreshold) {
|
||||
existingView?.renderer?.next();
|
||||
view?.renderer.next();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { BookDoc, DocumentLoader } from '@/libs/document';
|
||||
|
||||
import Spinner from '@/components/Spinner';
|
||||
import FoliateViewer from './FoliateViewer';
|
||||
|
||||
interface ReaderContentProps {
|
||||
isClosingBook: boolean;
|
||||
}
|
||||
|
||||
const ReaderContent: React.FC<ReaderContentProps> = ({ isClosingBook }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get('id');
|
||||
|
||||
const [bookDoc, setBookDoc] = React.useState<BookDoc>();
|
||||
const { envConfig } = useEnv();
|
||||
const { library, books, fetchBook, setLibrary } = useReaderStore();
|
||||
const defaultBookState = { loading: true, error: null, file: null, book: null, config: null };
|
||||
const bookState = id ? books[id] || defaultBookState : defaultBookState;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const { file, book, config } = bookState;
|
||||
if (isClosingBook) {
|
||||
if (book && config) {
|
||||
book.lastUpdated = Date.now();
|
||||
const bookIndex = library.findIndex((b) => b.hash === book.hash);
|
||||
if (bookIndex !== -1) {
|
||||
library[bookIndex] = book;
|
||||
}
|
||||
setLibrary(library);
|
||||
envConfig.initAppService().then((appService) => {
|
||||
config.lastUpdated = Date.now();
|
||||
appService.saveBookConfig(book, config);
|
||||
appService.saveLibraryBooks(library);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id && !file) {
|
||||
envConfig.initAppService().then((appService) => {
|
||||
fetchBook(appService, id);
|
||||
});
|
||||
}
|
||||
if (!bookDoc && bookState.file) {
|
||||
const loadDocument = async () => {
|
||||
if (file) {
|
||||
const { book } = await new DocumentLoader(file).open();
|
||||
setBookDoc(book);
|
||||
}
|
||||
};
|
||||
loadDocument();
|
||||
}
|
||||
return;
|
||||
}, [isClosingBook, bookState.file, envConfig, fetchBook, id]);
|
||||
|
||||
if (!id || !bookDoc || !bookState.config) {
|
||||
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>
|
||||
)}
|
||||
<FoliateViewer bookId={id} bookDoc={bookDoc} bookConfig={bookState.config} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReaderContent;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { FoliateView } from '@/app/reader/components/FoliateViewer';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
|
||||
type FoliateEventHandler = {
|
||||
onLoad?: (event: Event) => void;
|
||||
onRelocate?: (event: Event) => void;
|
||||
};
|
||||
|
||||
export const useFoliateEvents = (
|
||||
view: FoliateView | null,
|
||||
bookId: string,
|
||||
handlers?: FoliateEventHandler,
|
||||
) => {
|
||||
const setProgress = useReaderStore((state) => state.setProgress);
|
||||
|
||||
const defaultLoadHandler = (event: Event) => {
|
||||
console.log('load event:', event);
|
||||
};
|
||||
|
||||
const defaultRelocateHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
// console.log('relocate:', detail);
|
||||
setProgress(bookId, detail.fraction, detail.cfi, detail.location);
|
||||
};
|
||||
|
||||
const onLoad = handlers?.onLoad || defaultLoadHandler;
|
||||
const onRelocate = handlers?.onRelocate || defaultRelocateHandler;
|
||||
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
|
||||
view.addEventListener('load', onLoad);
|
||||
view.addEventListener('relocate', onRelocate);
|
||||
|
||||
return () => {
|
||||
view.removeEventListener('load', onLoad);
|
||||
view.removeEventListener('relocate', onRelocate);
|
||||
};
|
||||
}, [view, handlers]);
|
||||
};
|
||||
@@ -1,18 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ReaderContent from '@/components/ReaderContent';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
|
||||
import NavBar from '@/components/NavBar';
|
||||
import ReaderContent from './components/ReaderContent';
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -20,6 +28,15 @@ const ReaderPage = () => {
|
||||
setIsNavBarVisible((pre) => !pre);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLibrary = async () => {
|
||||
const appService = await envConfig.initAppService();
|
||||
setLibrary(await appService.loadLibraryBooks());
|
||||
};
|
||||
|
||||
fetchLibrary();
|
||||
}, [setLibrary]);
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-100'>
|
||||
<div
|
||||
@@ -28,7 +45,7 @@ const ReaderPage = () => {
|
||||
/>
|
||||
<NavBar onBack={handleBack} isVisible={isNavBarVisible} />
|
||||
<Suspense>
|
||||
<ReaderContent />
|
||||
<ReaderContent isClosingBook={isClosingBook} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { BookDoc, DocumentLoader } from '@/libs/document';
|
||||
|
||||
import Spinner from '@/components/Spinner';
|
||||
import FoliateViewer from './FoliateViewer';
|
||||
|
||||
const ReaderContent = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get('id');
|
||||
|
||||
const [bookDoc, setBookDoc] = React.useState<BookDoc>();
|
||||
const { envConfig } = useEnv();
|
||||
const { books, fetchBook } = useReaderStore();
|
||||
const bookState = books[id!] || { loading: true, content: null, error: null };
|
||||
|
||||
React.useEffect(() => {
|
||||
if (id && !bookState.content) {
|
||||
envConfig.initAppService().then((appService) => {
|
||||
fetchBook(appService, id).then((book) => {
|
||||
if (book) {
|
||||
book.lastUpdated = Date.now();
|
||||
appService.updateLibraryBook(book);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
const loadDocument = async () => {
|
||||
const content = bookState.content;
|
||||
if (content) {
|
||||
const { book } = await new DocumentLoader(content.file).open();
|
||||
setBookDoc(book);
|
||||
}
|
||||
};
|
||||
if (bookState.content) {
|
||||
loadDocument();
|
||||
}
|
||||
}, [bookState.content, envConfig, fetchBook, id]);
|
||||
|
||||
if (!bookDoc) {
|
||||
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>
|
||||
)}
|
||||
<FoliateViewer book={bookDoc} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReaderContent;
|
||||
@@ -189,15 +189,7 @@ export abstract class BaseAppService implements AppService {
|
||||
}
|
||||
|
||||
async saveLibraryBooks(books: Book[]): Promise<void> {
|
||||
await this.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(books));
|
||||
}
|
||||
|
||||
async updateLibraryBook(book: Book): Promise<void> {
|
||||
const library = await this.loadLibraryBooks();
|
||||
const bookIndex = library.findIndex((b) => b.hash === book.hash);
|
||||
if (bookIndex !== -1) {
|
||||
library[bookIndex] = book;
|
||||
}
|
||||
await this.saveLibraryBooks(library);
|
||||
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
|
||||
await this.fs.writeFile(getLibraryFilename(), 'Books', JSON.stringify(libraryBooks));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { AppService } from '@/types/system';
|
||||
|
||||
let appService: AppService | null = null;
|
||||
|
||||
export interface EnvConfigType {
|
||||
initAppService: () => Promise<AppService>;
|
||||
}
|
||||
|
||||
const environmentConfig: EnvConfigType = {
|
||||
initAppService: async () => {
|
||||
const { NativeAppService } = await import('@/services/nativeAppService');
|
||||
const appService = new NativeAppService();
|
||||
await appService.loadSettings();
|
||||
if (!appService) {
|
||||
const { NativeAppService } = await import('@/services/nativeAppService');
|
||||
appService = new NativeAppService();
|
||||
await appService.loadSettings();
|
||||
}
|
||||
return appService;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,69 +1,106 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { BookNote, BookContent, Book } from '@/types/book';
|
||||
import { BookNote, BookContent, Book, BookConfig } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
|
||||
interface BookState {
|
||||
loading?: boolean;
|
||||
content?: BookContent | null;
|
||||
error?: string | null;
|
||||
notes?: BookNote[];
|
||||
book?: Book | null;
|
||||
file?: File | null;
|
||||
config?: BookConfig | null;
|
||||
fraction?: number;
|
||||
}
|
||||
|
||||
interface ReaderStore {
|
||||
library: Book[];
|
||||
books: Record<string, BookState>;
|
||||
|
||||
setLibrary: (books: Book[]) => void;
|
||||
fetchBook: (appService: AppService, id: string) => Promise<Book | null>;
|
||||
addNote: (id: string, note: BookNote) => void;
|
||||
setProgress: (id: string, progress: number, location: string, pageinfo: {}) => void;
|
||||
addBookmark: (id: string, bookmark: BookNote) => void;
|
||||
}
|
||||
|
||||
export const useReaderStore = create<ReaderStore>((set) => {
|
||||
return {
|
||||
books: {},
|
||||
export const useReaderStore = create<ReaderStore>((set) => ({
|
||||
library: [],
|
||||
books: {},
|
||||
|
||||
setLibrary: (books: Book[]) => set({ library: books }),
|
||||
|
||||
fetchBook: async (appService: AppService, id: string) => {
|
||||
set((state) => ({
|
||||
books: {
|
||||
...state.books,
|
||||
[id]: { loading: true, file: null, book: null, config: null, error: null, notes: [] },
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const library = await appService.loadLibraryBooks();
|
||||
const book = library.find((b) => b.hash === id);
|
||||
if (!book) {
|
||||
throw new Error('Book not found');
|
||||
}
|
||||
const content = (await appService.loadBookContent(book)) as BookContent;
|
||||
const { file, config } = content;
|
||||
|
||||
fetchBook: async (appService: AppService, id: string) => {
|
||||
set((state) => ({
|
||||
books: {
|
||||
...state.books,
|
||||
[id]: { loading: true, content: null, error: null, notes: [] },
|
||||
[id]: { ...state.books[id], loading: false, book, file, config },
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const library = await appService.loadLibraryBooks();
|
||||
const book = library.find((b) => b.hash === id);
|
||||
if (!book) {
|
||||
throw new Error('Book not found');
|
||||
}
|
||||
const content = (await appService.loadBookContent(book)) as BookContent;
|
||||
|
||||
set((state) => ({
|
||||
books: {
|
||||
...state.books,
|
||||
[id]: { ...state.books[id], loading: false, content },
|
||||
},
|
||||
}));
|
||||
return book;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
set((state) => ({
|
||||
books: {
|
||||
...state.books,
|
||||
[id]: { ...state.books[id], loading: false, error: 'Failed to load book.' },
|
||||
},
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
addNote: (id: string, note: BookNote) =>
|
||||
return book;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
set((state) => ({
|
||||
books: {
|
||||
...state.books,
|
||||
[id]: { ...state.books[id], loading: false, error: 'Failed to load book.' },
|
||||
},
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setProgress: (id: string, progress: number, location: string, pageinfo: {}) =>
|
||||
set((state) => {
|
||||
const book = state.books[id];
|
||||
if (!book) return state;
|
||||
return {
|
||||
books: {
|
||||
...state.books,
|
||||
[id]: {
|
||||
...state.books[id],
|
||||
notes: [...state.books[id]!.notes!, note],
|
||||
...book,
|
||||
config: {
|
||||
...book.config,
|
||||
lastUpdated: Date.now(),
|
||||
progress,
|
||||
location,
|
||||
pageinfo,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
};
|
||||
}),
|
||||
|
||||
addBookmark: (id: string, bookmark: BookNote) =>
|
||||
set((state) => {
|
||||
const book = state.books[id];
|
||||
if (!book) return state;
|
||||
return {
|
||||
books: {
|
||||
...state.books,
|
||||
[id]: {
|
||||
...book,
|
||||
config: {
|
||||
...book.config,
|
||||
lastUpdated: Date.now(),
|
||||
bookmarks: [...(book.config?.bookmarks || []), bookmark],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -25,10 +25,11 @@ export interface BookNote {
|
||||
|
||||
export interface BookConfig {
|
||||
lastUpdated: number;
|
||||
remoteProgress: number;
|
||||
localProgress: number;
|
||||
bookmarks: BookNote[];
|
||||
annotations: BookNote[];
|
||||
progress?: number;
|
||||
location?: string;
|
||||
pageinfo?: Record<string, number>;
|
||||
bookmarks?: BookNote[];
|
||||
annotations?: BookNote[];
|
||||
removedNotesTimestamps?: Record<string, number>;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,5 @@ export interface AppService {
|
||||
loadBookContent(book: Book): Promise<BookContent>;
|
||||
loadLibraryBooks(): Promise<Book[]>;
|
||||
saveLibraryBooks(books: Book[]): Promise<void>;
|
||||
updateLibraryBook(book: Book): Promise<void>;
|
||||
getCoverImageUrl(book: Book): string;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,4 @@ export const getBaseFilename = (filename: string) => {
|
||||
};
|
||||
export const INIT_BOOK_CONFIG: BookConfig = {
|
||||
lastUpdated: 0,
|
||||
remoteProgress: 0,
|
||||
localProgress: 0,
|
||||
bookmarks: [],
|
||||
annotations: [],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user