Add a working reader page with default foliate settings

This commit is contained in:
chrox
2024-10-09 21:36:48 +02:00
parent 39def9f36a
commit f463225018
18 changed files with 320 additions and 4116 deletions
+1
View File
@@ -0,0 +1 @@
packages/foliate-js/
+1
View File
@@ -15,6 +15,7 @@ const nextConfig = {
},
// Configure assetPrefix or else the server won't properly resolve your assets.
assetPrefix: isProd ? null : `http://${internalHost}:3000`,
reactStrictMode: true,
};
export default nextConfig;
+2 -1
View File
@@ -23,7 +23,8 @@
"next": "14.2.13",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.3.0"
"react-icons": "^5.3.0",
"zustand": "5.0.0-rc.2"
},
"devDependencies": {
"@tauri-apps/cli": "2.0.2",
+1 -1
View File
@@ -3026,7 +3026,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-build 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
+7
View File
@@ -26,6 +26,13 @@ body {
}
}
foliate-view, foliate-view iframe {
display: block;
width: 100%;
height: 100vh;
border: none;
}
/* Hide scrollbar for Chrome, Safari and Opera */
::-webkit-scrollbar {
display: none;
+3 -2
View File
@@ -1,6 +1,7 @@
import * as React from 'react';
import type { Metadata } from 'next';
import { AuthProvider } from '../context/AuthContext';
import { EnvProvider } from '../context/EnvContext';
import { AuthProvider } from '@/context/AuthContext';
import { EnvProvider } from '@/context/EnvContext';
import './globals.css';
export const metadata: Metadata = {
+7 -10
View File
@@ -1,26 +1,24 @@
'use client';
import * as React from 'react';
import { useState } from 'react';
import { useState, useRef } from 'react';
import { Book } from '@/types/book';
import { useEnv } from '@/context/EnvContext';
import Navbar from '@/components/Navbar';
import SearchBar from '@/components/SearchBar';
import Spinner from '@/components/Spinner';
import Bookshelf from '@/components/Bookshelf';
type AppState = 'Init' | 'Loading' | 'Library' | 'Reader';
const LibraryPage = () => {
const { envConfig } = useEnv();
const [appState, setAppState] = useState<AppState>('Init');
const [libraryBooks, setLibraryBooks] = useState<Book[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const isInitiating = useRef(false);
React.useEffect(() => {
if (appState !== 'Init') return;
setAppState('Loading');
if (isInitiating.current) return;
isInitiating.current = true;
setLoading(true);
envConfig.appService().then((appService) => {
appService.loadSettings().then((settings) => {
@@ -29,7 +27,6 @@ const LibraryPage = () => {
.loadLibraryBooks()
.then((libraryBooks) => {
setLibraryBooks(libraryBooks);
setAppState('Library');
setLoading(false);
})
.catch((err) => {
@@ -39,7 +36,7 @@ const LibraryPage = () => {
});
});
});
}, [envConfig, appState]);
}, [envConfig]);
const handleImportBooks = async () => {
console.log('Importing books...');
@@ -56,7 +53,7 @@ const LibraryPage = () => {
return (
<div className='min-h-screen bg-gray-100'>
<Navbar onImportBooks={handleImportBooks} />
<SearchBar onImportBooks={handleImportBooks} />
<div className='min-h-screen p-2 pt-16'>
<div className='hero-content'>
<Spinner loading={loading} />
+59
View File
@@ -0,0 +1,59 @@
'use client';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import Spinner from '@/components/Spinner';
import ReaderContent from '@/components/ReaderContent';
import NavBar from '@/components/Navbar';
import { useReaderStore } from '@/store/readerStore';
import { useEnv } from '@/context/EnvContext';
const ReaderPage = () => {
const searchParams = useSearchParams();
const router = useRouter();
const id = searchParams.get('id');
const [isNavBarVisible, setIsNavBarVisible] = useState(false);
const { envConfig } = useEnv();
const { books, fetchBook } = useReaderStore();
const bookState = books[id!] || { loading: true, content: null, error: null };
useEffect(() => {
envConfig.appService().then((appService) => {
appService.loadSettings().then(() => {
if (id && !bookState.content) {
fetchBook(appService, id);
}
});
});
}, [id, fetchBook, bookState.content, envConfig]);
const handleBack = () => {
console.log('Back to bookshelf');
router.back();
};
const handleTap = () => {
setIsNavBarVisible((pre) => !pre);
};
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} />
{bookState.loading && <Spinner loading={bookState.loading} />}
{bookState.error && (
<div className='text-center'>
<h2 className='text-red-500'>{bookState.error}</h2>
</div>
)}
{bookState.content && <ReaderContent content={bookState.content!} />}
</div>
);
};
export default ReaderPage;
+11 -2
View File
@@ -1,5 +1,7 @@
import * as React from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { Book, BooksGroup } from '../types/book';
import { FaPlus } from 'react-icons/fa';
@@ -44,16 +46,23 @@ interface BookshelfProps {
}
const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, onImportBooks }) => {
const router = useRouter();
const bookshelfItems = generateBookshelfItems(libraryBooks);
const handleBookClick = (id: string) => {
router.push(`/reader?id=${id}`);
};
return (
<div>
<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'>
{bookshelfItems.map((item, index) => (
<div key={`library-item-${index}`} className=''>
<div className='grid gap-2'>
{'format' in item ? (
<div>
<div className='bookItem' onClick={() => handleBookClick(item.hash)}>
<div key={(item as Book).hash} className='card bg-base-100 w-full shadow-md'>
<Image
width={10}
@@ -0,0 +1,90 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { BookDoc } from '@/libs/document';
type FoliateViewerProps = {
book: BookDoc | null;
};
const getCSS = (spacing: number, justify: boolean, hyphenate: boolean) => `
@namespace epub "http://www.idpf.org/2007/ops";
html {
color-scheme: light dark;
}
/* https://github.com/whatwg/html/issues/5426 */
@media (prefers-color-scheme: dark) {
a:link {
color: lightblue;
}
}
p, li, blockquote, dd {
line-height: ${spacing};
text-align: ${justify ? 'justify' : 'start'};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
hyphens: ${hyphenate ? 'auto' : 'manual'};
-webkit-hyphenate-limit-before: 3;
-webkit-hyphenate-limit-after: 2;
-webkit-hyphenate-limit-lines: 2;
hanging-punctuation: allow-end last;
widows: 2;
}
/* prevent the above from overriding the align attribute */
[align="left"] { text-align: left; }
[align="right"] { text-align: right; }
[align="center"] { text-align: center; }
[align="justify"] { text-align: justify; }
pre {
white-space: pre-wrap !important;
}
aside[epub|type~="endnote"],
aside[epub|type~="footnote"],
aside[epub|type~="note"],
aside[epub|type~="rearnote"] {
display: none;
}
`;
const FoliateViewer: React.FC<FoliateViewerProps> = ({ book }) => {
const viewRef = useRef<HTMLDivElement>(null);
const isViewCreated = useRef(false);
useEffect(() => {
if (isViewCreated.current) return;
const openBook = async () => {
await import('foliate-js/view.js');
const view = document.createElement('foliate-view');
document.body.append(view);
viewRef.current?.appendChild(view);
console.log('Open the book with foliate-view:', book);
await view.open(book);
if ('setStyles' in view.renderer) {
view.renderer.setStyles(getCSS(1.4, true, true));
}
await view.renderer.next();
};
openBook();
isViewCreated.current = true;
}, [book]);
const handleTap = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const { clientX } = event;
const width = window.innerWidth;
const leftThreshold = width * 0.5;
const rightThreshold = width * 0.5;
const existingView = viewRef.current?.querySelector('foliate-view');
if (clientX < leftThreshold) {
existingView?.renderer?.prev();
} else if (clientX > rightThreshold) {
existingView?.renderer?.next();
}
};
return <div ref={viewRef} onClick={handleTap} />;
};
export default FoliateViewer;
@@ -0,0 +1,25 @@
'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;
@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import { BookContent } from '@/types/book';
import { BookDoc, DocumentLoader } from '@/libs/document';
import FoliateViewer from './FoliateViewer';
interface ReaderContentProps {
content: BookContent;
}
const ReaderContent: React.FC<ReaderContentProps> = ({ content }) => {
const [bookDoc, setBookDoc] = React.useState<BookDoc>();
React.useEffect(() => {
const loadDocument = async () => {
if (content.file) {
const { book } = await new DocumentLoader(content.file).open();
setBookDoc(book);
}
};
loadDocument();
}, [content.file]);
if (!content.file || !bookDoc) {
return null;
}
return (
<div>
<FoliateViewer book={bookDoc} />
</div>
);
};
export default ReaderContent;
@@ -1,11 +1,11 @@
import * as React from 'react';
import { FaSearch, FaPlus } from 'react-icons/fa';
interface NavbarProps {
interface SearchBarProps {
onImportBooks: () => void;
}
const Navbar: React.FC<NavbarProps> = ({ onImportBooks }) => {
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'>
@@ -39,4 +39,4 @@ const Navbar: React.FC<NavbarProps> = ({ onImportBooks }) => {
);
};
export default Navbar;
export default SearchBar;
+4 -3
View File
@@ -1,5 +1,6 @@
import { BookFormat } from '@/types/book';
// A groupBy polyfill for foliate-js
Object.groupBy ??= (iterable, callbackfn) => {
const obj = Object.create(null);
let i = 0;
@@ -29,7 +30,7 @@ Map.groupBy ??= (iterable, callbackfn) => {
return map;
};
type DocumentFile = File;
export type DocumentFile = File;
export interface BookDoc {
metadata: {
@@ -51,9 +52,9 @@ export const EXTS: Record<BookFormat, string> = {
};
export class DocumentLoader {
private file: DocumentFile;
private file: File;
constructor(file: DocumentFile) {
constructor(file: File) {
this.file = file;
}
+67
View File
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import { BookNote, BookContent } from '@/types/book';
import { AppService } from '@/types/system';
interface BookState {
loading?: boolean;
content?: BookContent | null;
error?: string | null;
notes?: BookNote[];
}
interface ReaderStore {
books: Record<string, BookState>;
fetchBook: (appService: AppService, id: string) => Promise<void>;
addNote: (id: string, note: BookNote) => void;
}
export const useReaderStore = create<ReaderStore>((set) => {
return {
books: {},
fetchBook: async (appService: AppService, id: string) => {
set((state) => ({
books: {
...state.books,
[id]: { loading: true, content: 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;
set((state) => ({
books: {
...state.books,
[id]: { ...state.books[id], loading: false, content },
},
}));
} catch (error) {
console.error(error);
set((state) => ({
books: {
...state.books,
[id]: { ...state.books[id], loading: false, error: 'Failed to load book.' },
},
}));
}
},
addNote: (id: string, note: BookNote) =>
set((state) => ({
books: {
...state.books,
[id]: {
...state.books[id],
notes: [...state.books[id]!.notes!, note],
},
},
})),
};
});
-4092
View File
File diff suppressed because it is too large Load Diff