Add a working reader page with default foliate settings
This commit is contained in:
@@ -0,0 +1 @@
|
||||
packages/foliate-js/
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+3
-3
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
+1
-1
Submodule packages/foliate-js updated: e384aaf6dd...b744c2d17e
+1
-1
Submodule packages/tauri updated: 04fd3a7db5...4731f0cf31
Generated
-4092
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user