Support running Readest in modern browsers

Now we support Web platform
This commit is contained in:
chrox
2024-12-05 18:37:28 +01:00
parent 23eb2514bc
commit aa16bc09f1
16 changed files with 328 additions and 51 deletions
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_APP_PLATFORM=tauri
+1
View File
@@ -0,0 +1 @@
NEXT_PUBLIC_APP_PLATFORM=web
+7 -4
View File
@@ -1,11 +1,14 @@
{
"name": "@readest/readest-app",
"version": "0.8.0",
"version": "0.8.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"dev": "dotenv -e .env.tauri -- next dev",
"build": "dotenv -e .env.tauri -- next build",
"start": "dotenv -e .env.tauri -- next start",
"dev-web": "dotenv -e .env.web -- next dev",
"build-web": "dotenv -e .env.web -- next build",
"start-web": "dotenv -e .env.web -- next start",
"lint": "next lint",
"tauri": "tauri",
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs",
+4 -1
View File
@@ -6,6 +6,7 @@ import { AuthProvider } from '@/context/AuthContext';
import { EnvProvider } from '@/context/EnvContext';
import { CSPostHogProvider } from '@/context/PHContext';
import { useTheme } from '@/hooks/useTheme';
import { isTauriAppPlatform } from '@/services/environment';
import { checkForAppUpdates } from '@/helpers/updater';
import '../styles/globals.css';
@@ -20,7 +21,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
React.useEffect(() => {
const doAppUpdates = async () => {
await checkForAppUpdates();
if (isTauriAppPlatform()) {
await checkForAppUpdates();
}
};
doAppUpdates();
}, []);
@@ -4,6 +4,7 @@ import { FaSearch } from 'react-icons/fa';
import { PiPlus } from 'react-icons/pi';
import { PiSelectionAllDuotone } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import useTrafficLight from '@/hooks/useTrafficLight';
import WindowButtons from '@/components/WindowButtons';
@@ -18,6 +19,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onImportBooks,
onToggleSelectMode,
}) => {
const { appService } = useEnv();
const { isTrafficLightVisible } = useTrafficLight();
const headerRef = useRef<HTMLDivElement>(null);
@@ -85,9 +87,9 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
</div>
<WindowButtons
headerRef={headerRef}
showMinimize={!isTrafficLightVisible}
showMaximize={!isTrafficLightVisible}
showClose={!isTrafficLightVisible}
showMinimize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
showMaximize={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
showClose={!isTrafficLightVisible && appService?.appPlatform !== 'web'}
/>
</div>
</div>
+20 -11
View File
@@ -4,16 +4,19 @@ import * as React from 'react';
import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Book } from '@/types/book';
import { AppService } from '@/types/system';
import { parseOpenWithFiles } from '@/helpers/cli';
import { isTauriAppPlatform } from '@/services/environment';
import { FILE_ACCEPT_FORMATS, SUPPORTED_FILE_EXTS } from '@/services/constants';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { Book } from '@/types/book';
import { parseOpenWithFiles } from '@/helpers/cli';
import Spinner from '@/components/Spinner';
import LibraryHeader from '@/app/library/components/LibraryHeader';
import Bookshelf from '@/app/library/components/Bookshelf';
import { AppService } from '@/types/system';
const LibraryPage = () => {
const router = useRouter();
@@ -55,16 +58,17 @@ const LibraryPage = () => {
if (isInitiating.current) return;
isInitiating.current = true;
const loadingTimeout = setTimeout(() => setLoading(true), 200);
const loadingTimeout = setTimeout(() => setLoading(true), 300);
const initLibrary = async () => {
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
setSettings(settings);
const libraryBooks = await appService.loadLibraryBooks();
if (checkOpenWithBooks) {
if (checkOpenWithBooks && isTauriAppPlatform()) {
await handleOpenWithBooks(appService, libraryBooks);
} else {
clearOpenWithBooks();
setLibrary(libraryBooks);
}
@@ -102,14 +106,14 @@ const LibraryPage = () => {
};
const selectFilesTauri = async () => {
return appService?.selectFiles('Select Books', ['epub', 'pdf']);
return appService?.selectFiles('Select Books', SUPPORTED_FILE_EXTS);
};
const selectFilesWeb = () => {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.epub, .pdf';
fileInput.accept = FILE_ACCEPT_FORMATS;
fileInput.multiple = true;
fileInput.click();
@@ -121,12 +125,17 @@ const LibraryPage = () => {
const handleImportBooks = async () => {
console.log('Importing books...');
const { type } = await import('@tauri-apps/plugin-os');
let files;
if (['android', 'ios'].includes(type())) {
files = (await selectFilesWeb()) as [File];
if (isTauriAppPlatform()) {
const { type } = await import('@tauri-apps/plugin-os');
if (['android', 'ios'].includes(type())) {
files = (await selectFilesWeb()) as [File];
} else {
files = (await selectFilesTauri()) as [string];
}
} else {
files = (await selectFilesTauri()) as [string];
files = (await selectFilesWeb()) as [File];
}
importBooks(files);
};
@@ -2,6 +2,7 @@ import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import { PiDotsThreeVerticalBold } from 'react-icons/pi';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import useTrafficLight from '@/hooks/useTrafficLight';
@@ -29,6 +30,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
onCloseBook,
onSetSettingsDialogOpen,
}) => {
const { appService } = useEnv();
const headerRef = useRef<HTMLDivElement>(null);
const { isTrafficLightVisible } = useTrafficLight();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@@ -78,8 +80,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<WindowButtons
className='window-buttons flex h-full items-center'
headerRef={headerRef}
showMinimize={bookKeys.length == 1}
showMaximize={bookKeys.length == 1}
showMinimize={bookKeys.length == 1 && appService?.appPlatform !== 'web'}
showMaximize={bookKeys.length == 1 && appService?.appPlatform !== 'web'}
onClose={() => onCloseBook(bookKey)}
/>
</div>
@@ -101,7 +101,7 @@ const ReaderContent: React.FC<{ settings: SystemSettings }> = ({ settings }) =>
if (!bookKeys || bookKeys.length === 0) return null;
const bookData = getBookData(bookKeys[0]!);
if (!bookData || !bookData.book || !bookData.bookDoc) {
setTimeout(() => setLoading(true), 200);
setTimeout(() => setLoading(true), 300);
return (
loading && (
<div className={'hero hero-content min-h-screen'}>
@@ -61,6 +61,10 @@ const DeepLPopup: React.FC<DeepLPopupProps> = ({
setError(null);
setTranslation(null);
if (!process.env['NEXT_PUBLIC_DEEPL_API_KEY']) {
console.error('DeepL API key not found. Set NEXT_PUBLIC_DEEPL_API_KEY in .env.local');
}
try {
const response = await fetch('https://api-free.deepl.com/v2/translate', {
method: 'POST',
@@ -30,7 +30,10 @@ const useTrafficLight = () => {
};
useEffect(() => {
if (!appService?.hasTrafficLight) return;
handleSwitchFullScreen();
return () => {
unlistenEnterFullScreen?.();
unlistenExitFullScreen?.();
+28 -9
View File
@@ -1,4 +1,4 @@
import { AppService, ToastType } from '@/types/system';
import { AppPlatform, AppService, ToastType } from '@/types/system';
import { SystemSettings } from '@/types/settings';
import { FileSystem, BaseDir } from '@/types/system';
@@ -26,14 +26,15 @@ import {
export abstract class BaseAppService implements AppService {
localBooksDir: string = '';
abstract fs: FileSystem;
abstract appPlatform: AppPlatform;
abstract isAppDataSandbox: boolean;
abstract hasTrafficLight: boolean;
abstract fs: FileSystem;
abstract resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string };
abstract getCoverImageUrl(book: Book): string;
abstract getCoverImageBlobUrl(book: Book): Promise<string>;
abstract getInitBooksDir(): Promise<string>;
abstract selectDirectory(title: string): Promise<string>;
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
abstract showMessage(
msg: string,
@@ -133,8 +134,6 @@ export abstract class BaseAppService implements AppService {
author: loadedBook.metadata.author,
lastUpdated: Date.now(),
};
book.coverImageUrl = this.getCoverImageUrl(book);
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
@@ -156,6 +155,13 @@ export abstract class BaseAppService implements AppService {
await this.saveBookConfig(book, INIT_BOOK_CONFIG);
books.splice(0, 0, book);
}
if (this.appPlatform === 'web') {
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
} else {
book.coverImageUrl = this.getCoverImageUrl(book);
}
return book;
} catch (error) {
throw error;
@@ -173,7 +179,13 @@ export abstract class BaseAppService implements AppService {
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
const fp = getFilename(book);
const file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
let file: File;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(fp, 'Books', 'binary');
file = new File([content], fp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open();
}
return { book, file, config: await this.loadBookConfig(book, settings) };
}
@@ -222,9 +234,16 @@ export abstract class BaseAppService implements AppService {
await this.fs.writeFile(libraryFilename, 'Books', '[]');
}
books.forEach((book) => {
book.coverImageUrl = this.getCoverImageUrl(book);
});
await Promise.all(
books.map(async (book) => {
if (this.appPlatform === 'web') {
book.coverImageUrl = await this.getCoverImageBlobUrl(book);
} else {
book.coverImageUrl = this.getCoverImageUrl(book);
}
return book;
}),
);
return books;
}
@@ -4,6 +4,9 @@ import { ReadSettings } from '@/types/settings';
export const LOCAL_BOOKS_SUBDIR = 'Readest/Books';
export const CLOUD_BOOKS_SUBDIR = 'Readest/Books';
export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'pdf'];
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '25%',
isSideBarPinned: true,
+26 -6
View File
@@ -1,19 +1,39 @@
import { AppService } from '@/types/system';
let appService: AppService | null = null;
export const isTauriAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri';
export const isWebAppPlatform = () => process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
export interface EnvConfigType {
getAppService: () => Promise<AppService>;
}
let nativeAppService: AppService | null = null;
const getNativeAppService = async () => {
if (!nativeAppService) {
const { NativeAppService } = await import('@/services/nativeAppService');
nativeAppService = new NativeAppService();
await nativeAppService.loadSettings();
}
return nativeAppService;
};
let webAppService: AppService | null = null;
const getWebAppService = async () => {
if (!webAppService) {
const { WebAppService } = await import('@/services/webAppService');
webAppService = new WebAppService();
await webAppService.loadSettings();
}
return webAppService;
};
const environmentConfig: EnvConfigType = {
getAppService: async () => {
if (!appService) {
const { NativeAppService } = await import('@/services/nativeAppService');
appService = new NativeAppService();
await appService.loadSettings();
if (isTauriAppPlatform()) {
return getNativeAppService();
} else {
return getWebAppService();
}
return appService;
},
};
@@ -16,7 +16,7 @@ import { join, appDataDir } from '@tauri-apps/api/path';
import { type as osType } from '@tauri-apps/plugin-os';
import { Book } from '@/types/book';
import { ToastType, FileSystem, BaseDir } from '@/types/system';
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { BaseAppService } from './appService';
@@ -24,10 +24,7 @@ import { LOCAL_BOOKS_SUBDIR } from './constants';
export const isMobile = ['android', 'ios'].includes(osType());
export const resolvePath = (
fp: string,
base: BaseDir,
): { baseDir: number; base: BaseDir; fp: string } => {
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
switch (base) {
case 'Settings':
return { baseDir: BaseDirectory.AppConfig, fp, base };
@@ -56,6 +53,10 @@ export const nativeFileSystem: FileSystem = {
getURL(path: string) {
return convertFileSrc(path);
},
async getBlobURL(path: string, base: BaseDir) {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp, baseDir } = resolvePath(dstPath, base);
await copyFile(srcPath, fp, base && { toPathBaseDir: baseDir });
@@ -114,6 +115,7 @@ export const nativeFileSystem: FileSystem = {
export class NativeAppService extends BaseAppService {
fs = nativeFileSystem;
appPlatform = 'tauri' as AppPlatform;
isAppDataSandbox = isMobile;
hasTrafficLight = osType() === 'macos';
@@ -125,14 +127,6 @@ export class NativeAppService extends BaseAppService {
return join(await appDataDir(), LOCAL_BOOKS_SUBDIR);
}
async selectDirectory(title: string): Promise<string> {
const selected = await open({
title,
directory: true,
});
return selected as string;
}
async selectFiles(name: string, extensions: string[]): Promise<string[]> {
const selected = await open({
multiple: true,
@@ -153,4 +147,8 @@ export class NativeAppService extends BaseAppService {
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${this.localBooksDir}/${getCoverFilename(book)}`);
};
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
return this.fs.getBlobURL(`${this.localBooksDir}/${getCoverFilename(book)}`, 'None');
};
}
@@ -0,0 +1,206 @@
import { Book } from '@/types/book';
import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system';
import { getCoverFilename } from '@/utils/book';
import { BaseAppService } from './appService';
import { LOCAL_BOOKS_SUBDIR } from './constants';
const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } => {
switch (base) {
case 'Books':
return { baseDir: 0, fp: `${LOCAL_BOOKS_SUBDIR}/${fp}`, base };
case 'None':
return { baseDir: 0, fp, base };
default:
return { baseDir: 0, fp: `${base}/${fp}`, base };
}
};
const dbName = 'AppFileSystem';
const dbVersion = 1;
async function openIndexedDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, dbVersion);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('files')) {
db.createObjectStore('files', { keyPath: 'path' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
const indexedDBFileSystem: FileSystem = {
getURL(path: string) {
return URL.createObjectURL(new Blob([path]));
},
async getBlobURL(path: string, base: BaseDir) {
try {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
} catch {
return path;
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp } = resolvePath(dstPath, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
const getRequest = store.get(srcPath);
getRequest.onsuccess = () => {
const data = getRequest.result;
if (data) {
store.put({ path: fp, content: data.content });
resolve();
} else {
reject(new Error(`File not found: ${srcPath}`));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<string | ArrayBuffer>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = async () => {
if (request.result) {
const content = request.result.content;
if (mode === 'text') resolve(content);
else {
if (content instanceof Blob) {
const arrayBuffer = await content.arrayBuffer();
resolve(arrayBuffer);
} else if (content instanceof ArrayBuffer) {
resolve(content);
} else if (typeof content === 'string') {
resolve(new TextEncoder().encode(content).buffer as ArrayBuffer);
} else {
reject(new Error('Unsupported content type in IndexedDB'));
}
}
} else {
reject(new Error(`File not found: ${fp}`));
}
};
request.onerror = () => reject(request.error);
});
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer) {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
store.put({ path: fp, content });
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async removeFile(path: string, base: BaseDir) {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
store.delete(fp);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async createDir() {
// Directories are virtual in IndexedDB; no-op
},
async removeDir() {
// Directories are virtual in IndexedDB; no-op
},
async readDir(path: string) {
const db = await openIndexedDB();
return new Promise<{ path: string; isDir: boolean }[]>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as { path: string }[];
resolve(
files
.filter((file) => file.path.startsWith(path))
.map((file) => ({ path: file.path, isDir: false })),
);
};
request.onerror = () => reject(request.error);
});
},
async exists(path: string, base: BaseDir) {
const { fp } = resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<boolean>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = () => resolve(!!request.result);
request.onerror = () => reject(request.error);
});
},
};
export class WebAppService extends BaseAppService {
fs = indexedDBFileSystem;
appPlatform = 'web' as AppPlatform;
isAppDataSandbox = false;
hasTrafficLight = false;
override resolvePath(fp: string, base: BaseDir): { baseDir: number; base: BaseDir; fp: string } {
return resolvePath(fp, base);
}
async getInitBooksDir(): Promise<string> {
return LOCAL_BOOKS_SUBDIR;
}
async selectDirectory(): Promise<string> {
throw new Error('selectDirectory is not supported in browser');
}
async selectFiles(): Promise<string[]> {
throw new Error('selectFiles is not supported in browser');
}
async showMessage(msg: string, kind: ToastType = 'info'): Promise<void> {
alert(`${kind.toUpperCase()}: ${msg}`);
}
getCoverImageUrl = (book: Book): string => {
return this.fs.getURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`);
};
getCoverImageBlobUrl = async (book: Book): Promise<string> => {
return this.fs.getBlobURL(`${LOCAL_BOOKS_SUBDIR}/${getCoverFilename(book)}`, 'None');
};
}
+4 -1
View File
@@ -1,11 +1,13 @@
import { SystemSettings } from './settings';
import { Book, BookConfig, BookContent } from './book';
export type AppPlatform = 'web' | 'tauri';
export type BaseDir = 'Books' | 'Settings' | 'Data' | 'Log' | 'Cache' | 'None';
export type ToastType = 'info' | 'warning' | 'error';
export interface FileSystem {
getURL(path: string): string;
getBlobURL(path: string, base: BaseDir): Promise<string>;
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer): Promise<void>;
@@ -18,10 +20,10 @@ export interface FileSystem {
export interface AppService {
fs: FileSystem;
appPlatform: AppPlatform;
hasTrafficLight: boolean;
isAppDataSandbox: boolean;
selectDirectory(title: string): Promise<string>;
selectFiles(name: string, extensions: string[]): Promise<string[]>;
showMessage(msg: string, kind?: ToastType, title?: string, okLabel?: string): Promise<void>;
@@ -35,4 +37,5 @@ export interface AppService {
loadLibraryBooks(): Promise<Book[]>;
saveLibraryBooks(books: Book[]): Promise<void>;
getCoverImageUrl(book: Book): string;
getCoverImageBlobUrl(book: Book): Promise<string>;
}