forked from akai/readest
Support running Readest in modern browsers
Now we support Web platform
This commit is contained in:
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=web
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user