Add demo library (books from Feedbooks)
This commit is contained in:
@@ -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<Book[]>([]);
|
||||
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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"library": ["https://cdn.readest.com/books/four-great-classics.epub"]
|
||||
}
|
||||
@@ -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<Book | null> {
|
||||
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<BookContent> {
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<IDBDatabase> {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,7 +29,13 @@ export interface AppService {
|
||||
|
||||
loadSettings(): Promise<SystemSettings>;
|
||||
saveSettings(settings: SystemSettings): Promise<void>;
|
||||
importBook(file: string | File, books: Book[], overwrite?: boolean): Promise<Book | null>;
|
||||
importBook(
|
||||
file: string | File,
|
||||
books: Book[],
|
||||
saveBook?: boolean,
|
||||
saveCover?: boolean,
|
||||
overwrite?: boolean,
|
||||
): Promise<Book | null>;
|
||||
deleteBook(book: Book): Promise<void>;
|
||||
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
|
||||
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user