diff --git a/apps/readest-app/src/app/library/hooks/useDemoBooks.ts b/apps/readest-app/src/app/library/hooks/useDemoBooks.ts new file mode 100644 index 00000000..2ba3a1e7 --- /dev/null +++ b/apps/readest-app/src/app/library/hooks/useDemoBooks.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from 'react'; + +import { useEnv } from '@/context/EnvContext'; +import { Book } from '@/types/book'; +import { getUserLang } from '@/utils/misc'; + +import libraryEn from '@/data/demo/library.en.json'; +import libraryZh from '@/data/demo/library.zh.json'; + +const libraries = { + en: libraryEn, + zh: libraryZh, +}; + +interface DemoBooks { + library: string[]; +} + +export const useDemoBooks = () => { + const { envConfig } = useEnv(); + const userLang = getUserLang() as keyof typeof libraries; + const [books, setBooks] = useState([]); + const isLoading = useRef(false); + + useEffect(() => { + if (isLoading.current) return; + isLoading.current = true; + + const fetchDemoBooks = async () => { + try { + const appService = await envConfig.getAppService(); + const demoBooks = libraries[userLang] || (libraries.en as DemoBooks); + const books = await Promise.all( + demoBooks.library.map((url) => appService.importBook(url, [], false, true)), + ); + setBooks(books.filter((book) => book !== null) as Book[]); + } catch (error) { + console.error('Failed to import demo books:', error); + } + }; + + const demoBooksFetchedFlag = localStorage.getItem('demoBooksFetched'); + if (!demoBooksFetchedFlag) { + fetchDemoBooks(); + localStorage.setItem('demoBooksFetched', 'true'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return books; +}; diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 97acec93..cee49d1a 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Book } from '@/types/book'; @@ -16,10 +16,11 @@ import { useEnv } from '@/context/EnvContext'; import { useTheme } from '@/hooks/useTheme'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; +import { useDemoBooks } from './hooks/useDemoBooks'; import Spinner from '@/components/Spinner'; -import LibraryHeader from '@/app/library/components/LibraryHeader'; -import Bookshelf from '@/app/library/components/Bookshelf'; +import LibraryHeader from './components/LibraryHeader'; +import Bookshelf from './components/Bookshelf'; const LibraryPage = () => { const router = useRouter(); @@ -36,8 +37,9 @@ const LibraryPage = () => { const isInitiating = useRef(false); const [libraryLoaded, setLibraryLoaded] = useState(false); const [isSelectMode, setIsSelectMode] = useState(false); + const demoBooks = useDemoBooks(); - React.useEffect(() => { + useEffect(() => { const doAppUpdates = async () => { if (isTauriAppPlatform()) { await checkForAppUpdates(); @@ -67,7 +69,7 @@ const LibraryPage = () => { [], ); - React.useEffect(() => { + useEffect(() => { if (isInitiating.current) return; isInitiating.current = true; @@ -108,6 +110,23 @@ const LibraryPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (demoBooks.length > 0 && libraryLoaded) { + const newLibrary = [...libraryBooks]; + for (const book of demoBooks) { + const idx = newLibrary.findIndex((b) => b.hash === book.hash); + if (idx === -1) { + newLibrary.push(book); + } else { + newLibrary[idx] = book; + } + } + setLibrary(newLibrary); + appService?.saveLibraryBooks(newLibrary); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [demoBooks, libraryLoaded]); + const importBooks = async (files: [string | File]) => { setLoading(true); for (const file of files) { diff --git a/apps/readest-app/src/data/demo/library.en.json b/apps/readest-app/src/data/demo/library.en.json new file mode 100644 index 00000000..325eed28 --- /dev/null +++ b/apps/readest-app/src/data/demo/library.en.json @@ -0,0 +1,10 @@ +{ + "library": [ + "https://cdn.readest.com/books/a-room-of-one-s-own.epub", + "https://cdn.readest.com/books/hamlet.epub", + "https://cdn.readest.com/books/meditations.epub", + "https://cdn.readest.com/books/the-great-gatsby.epub", + "https://cdn.readest.com/books/the-scarlet-letter.epub", + "https://cdn.readest.com/books/this-side-of-paradise.epub" + ] +} diff --git a/apps/readest-app/src/data/demo/library.zh.json b/apps/readest-app/src/data/demo/library.zh.json new file mode 100644 index 00000000..32f832b5 --- /dev/null +++ b/apps/readest-app/src/data/demo/library.zh.json @@ -0,0 +1,3 @@ +{ + "library": ["https://cdn.readest.com/books/four-great-classics.epub"] +} diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index df601c29..d3d20ae9 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -25,6 +25,7 @@ import { SYSTEM_SETTINGS_VERSION, DEFAULT_BOOK_SEARCH_CONFIG, } from './constants'; +import { isValidURL } from '@/utils/misc'; export abstract class BaseAppService implements AppService { localBooksDir: string = ''; @@ -95,6 +96,8 @@ export abstract class BaseAppService implements AppService { async importBook( file: string | File, books: Book[], + saveBook: boolean = true, + saveCover: boolean = true, overwrite: boolean = false, ): Promise { try { @@ -139,14 +142,14 @@ export abstract class BaseAppService implements AppService { if (!(await this.fs.exists(getDir(book), 'Books'))) { await this.fs.createDir(getDir(book), 'Books'); } - if (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite) { + if (saveBook && (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite)) { if (typeof file === 'string') { await this.fs.copyFile(file, getFilename(book), 'Books'); } else { await this.fs.writeFile(getFilename(book), 'Books', await file.arrayBuffer()); } } - if (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite) { + if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) { const cover = await loadedBook.getCover(); if (cover) { await this.fs.writeFile(getCoverFilename(book), 'Books', await cover.arrayBuffer()); @@ -158,6 +161,9 @@ export abstract class BaseAppService implements AppService { books.splice(0, 0, book); } + if (typeof file === 'string' && isValidURL(file)) { + book.url = file; + } if (this.appPlatform === 'web') { book.coverImageUrl = await this.getCoverImageBlobUrl(book); } else { @@ -180,13 +186,17 @@ export abstract class BaseAppService implements AppService { } async loadBookContent(book: Book, settings: SystemSettings): Promise { - const fp = getFilename(book); let file: File; - if (this.appPlatform === 'web') { - const content = await this.fs.readFile(fp, 'Books', 'binary'); - file = new File([content], fp); + if (book.url) { + file = await new RemoteFile(book.url).open(); } else { - file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), fp).open(); + const fp = getFilename(book); + 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) }; } diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index f021a6af..af830702 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -18,6 +18,7 @@ import { type as osType } from '@tauri-apps/plugin-os'; import { Book } from '@/types/book'; import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system'; import { getCoverFilename } from '@/utils/book'; +import { isValidURL } from '@/utils/misc'; import { BaseAppService } from './appService'; import { LOCAL_BOOKS_SUBDIR } from './constants'; @@ -51,7 +52,7 @@ const resolvePath = (fp: string, base: BaseDir): { baseDir: number; base: BaseDi export const nativeFileSystem: FileSystem = { getURL(path: string) { - return convertFileSrc(path); + return isValidURL(path) ? path : convertFileSrc(path); }, async getBlobURL(path: string, base: BaseDir) { const content = await this.readFile(path, base, 'binary'); diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index bc5525f7..c1982f54 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -1,6 +1,7 @@ import { Book } from '@/types/book'; import { ToastType, FileSystem, BaseDir, AppPlatform } from '@/types/system'; import { getCoverFilename } from '@/utils/book'; +import { isValidURL } from '@/utils/misc'; import { BaseAppService } from './appService'; import { LOCAL_BOOKS_SUBDIR } from './constants'; @@ -37,7 +38,11 @@ async function openIndexedDB(): Promise { const indexedDBFileSystem: FileSystem = { getURL(path: string) { - return URL.createObjectURL(new Blob([path])); + if (isValidURL(path)) { + return path; + } else { + return URL.createObjectURL(new Blob([path])); + } }, async getBlobURL(path: string, base: BaseDir) { try { diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index 0dc4eb7f..abfb7313 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -4,6 +4,8 @@ export type HighlightStyle = 'highlight' | 'underline' | 'squiggly'; export type HighlightColor = 'red' | 'yellow' | 'green' | 'blue' | 'violet'; export interface Book { + // if Book is a remote book we just lazy load the book content + url?: string; hash: string; format: BookFormat; title: string; diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 0796d613..bc2c603a 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -29,7 +29,13 @@ export interface AppService { loadSettings(): Promise; saveSettings(settings: SystemSettings): Promise; - importBook(file: string | File, books: Book[], overwrite?: boolean): Promise; + importBook( + file: string | File, + books: Book[], + saveBook?: boolean, + saveCover?: boolean, + overwrite?: boolean, + ): Promise; deleteBook(book: Book): Promise; loadBookConfig(book: Book, settings: SystemSettings): Promise; saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise; diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index db644c5e..d9b1f74b 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -1,6 +1,6 @@ import { EXTS } from '@/libs/document'; import { Book, BookConfig } from '@/types/book'; -import { makeSafeFilename } from './misc'; +import { getUserLang, makeSafeFilename } from './misc'; export const getDir = (book: Book) => { return `${book.hash}`; @@ -37,7 +37,7 @@ interface Contributor { name: LanguageMap; } -const userLang = navigator?.language || 'en'; +const userLang = getUserLang(); const formatLanguageMap = (x: string | LanguageMap): string => { if (!x) return ''; diff --git a/apps/readest-app/src/utils/misc.ts b/apps/readest-app/src/utils/misc.ts index 78afca74..fa86d88c 100644 --- a/apps/readest-app/src/utils/misc.ts +++ b/apps/readest-app/src/utils/misc.ts @@ -23,6 +23,8 @@ export const makeSafeFilename = (filename: string, replacement = '_') => { return safeName.trim(); }; +export const getUserLang = () => navigator?.language.split('-')[0] || 'en'; + export const getOSPlatform = () => { const userAgent = navigator.userAgent.toLowerCase(); @@ -34,3 +36,12 @@ export const getOSPlatform = () => { return ''; }; + +export const isValidURL = (url: string, allowedSchemes: string[] = ['http', 'https']) => { + try { + const { protocol } = new URL(url); + return allowedSchemes.some((scheme) => `${scheme}:` === protocol); + } catch { + return false; + } +};