forked from akai/readest
793 lines
27 KiB
TypeScript
793 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import clsx from 'clsx';
|
|
import * as React from 'react';
|
|
import { MdChevronRight } from 'react-icons/md';
|
|
import { useState, useRef, useEffect, Suspense, useCallback } from 'react';
|
|
import { ReadonlyURLSearchParams, useRouter, useSearchParams } from 'next/navigation';
|
|
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from 'overlayscrollbars-react';
|
|
import 'overlayscrollbars/overlayscrollbars.css';
|
|
|
|
import { Book } from '@/types/book';
|
|
import { AppService, DeleteAction } from '@/types/system';
|
|
import { navigateToLibrary, navigateToLogin, navigateToReader } from '@/utils/nav';
|
|
import { formatAuthors, formatTitle, getPrimaryLanguage, listFormater } from '@/utils/book';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { ProgressPayload } from '@/utils/transfer';
|
|
import { throttle } from '@/utils/throttle';
|
|
import { getFilename } from '@/utils/path';
|
|
import { parseOpenWithFiles } from '@/helpers/openWith';
|
|
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
|
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
|
|
import { impactFeedback } from '@tauri-apps/plugin-haptics';
|
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
|
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useThemeStore } from '@/store/themeStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useLibraryStore } from '@/store/libraryStore';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
|
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import { useUICSS } from '@/hooks/useUICSS';
|
|
import { useDemoBooks } from './hooks/useDemoBooks';
|
|
import { useBooksSync } from './hooks/useBooksSync';
|
|
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
|
|
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
|
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
|
|
import { lockScreenOrientation } from '@/utils/bridge';
|
|
import {
|
|
tauriHandleClose,
|
|
tauriHandleSetAlwaysOnTop,
|
|
tauriHandleToggleFullScreen,
|
|
tauriQuitApp,
|
|
} from '@/utils/window';
|
|
|
|
import { BookMetadata } from '@/libs/document';
|
|
import { AboutWindow } from '@/components/AboutWindow';
|
|
import { BookDetailModal } from '@/components/metadata';
|
|
import { UpdaterWindow } from '@/components/UpdaterWindow';
|
|
import { MigrateDataWindow } from './components/MigrateDataWindow';
|
|
import { useDragDropImport } from './hooks/useDragDropImport';
|
|
import { Toast } from '@/components/Toast';
|
|
import { getBreadcrumbs } from './utils/libraryUtils';
|
|
import Spinner from '@/components/Spinner';
|
|
import LibraryHeader from './components/LibraryHeader';
|
|
import Bookshelf from './components/Bookshelf';
|
|
import useShortcuts from '@/hooks/useShortcuts';
|
|
import DropIndicator from '@/components/DropIndicator';
|
|
import SettingsDialog from '@/components/settings/SettingsDialog';
|
|
|
|
const LibraryPageWithSearchParams = () => {
|
|
const searchParams = useSearchParams();
|
|
return <LibraryPageContent searchParams={searchParams} />;
|
|
};
|
|
|
|
const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchParams | null }) => {
|
|
const router = useRouter();
|
|
const { envConfig, appService } = useEnv();
|
|
const { token, user } = useAuth();
|
|
const {
|
|
library: libraryBooks,
|
|
isSyncing,
|
|
syncProgress,
|
|
updateBook,
|
|
setLibrary,
|
|
getGroupId,
|
|
getGroupName,
|
|
refreshGroups,
|
|
checkOpenWithBooks,
|
|
checkLastOpenBooks,
|
|
setCheckOpenWithBooks,
|
|
setCheckLastOpenBooks,
|
|
} = useLibraryStore();
|
|
const _ = useTranslation();
|
|
const { selectFiles } = useFileSelector(appService, _);
|
|
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
|
|
const { settings, setSettings, saveSettings } = useSettingsStore();
|
|
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
|
|
const [loading, setLoading] = useState(false);
|
|
const [libraryLoaded, setLibraryLoaded] = useState(false);
|
|
const [isSelectMode, setIsSelectMode] = useState(false);
|
|
const [isSelectAll, setIsSelectAll] = useState(false);
|
|
const [isSelectNone, setIsSelectNone] = useState(false);
|
|
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
|
const [currentGroupPath, setCurrentGroupPath] = useState<string | undefined>(undefined);
|
|
const [booksTransferProgress, setBooksTransferProgress] = useState<{
|
|
[key: string]: number | null;
|
|
}>({});
|
|
const [pendingNavigationBookIds, setPendingNavigationBookIds] = useState<string[] | null>(null);
|
|
const isInitiating = useRef(false);
|
|
|
|
const iconSize = useResponsiveSize(18);
|
|
const viewSettings = settings.globalViewSettings;
|
|
const demoBooks = useDemoBooks();
|
|
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
|
|
const containerRef: React.MutableRefObject<HTMLDivElement | null> = useRef(null);
|
|
const pageRef = useRef<HTMLDivElement>(null);
|
|
|
|
useTheme({ systemUIVisible: true, appThemeColor: 'base-200' });
|
|
useUICSS();
|
|
|
|
useOpenWithBooks();
|
|
|
|
const { pullLibrary, pushLibrary } = useBooksSync();
|
|
const { isDragging } = useDragDropImport();
|
|
|
|
usePullToRefresh(containerRef, pullLibrary);
|
|
useScreenWakeLock(settings.screenWakeLock);
|
|
|
|
useShortcuts({
|
|
onToggleFullscreen: async () => {
|
|
if (isTauriAppPlatform()) {
|
|
await tauriHandleToggleFullScreen();
|
|
}
|
|
},
|
|
onCloseWindow: async () => {
|
|
if (isTauriAppPlatform()) {
|
|
await tauriHandleClose();
|
|
}
|
|
},
|
|
onQuitApp: async () => {
|
|
if (isTauriAppPlatform()) {
|
|
await tauriQuitApp();
|
|
}
|
|
},
|
|
onOpenFontLayoutSettings: () => {
|
|
setSettingsDialogOpen(true);
|
|
},
|
|
onOpenBooks: () => {
|
|
handleImportBooks();
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
const doCheckAppUpdates = async () => {
|
|
if (appService?.hasUpdater && settings.autoCheckUpdates) {
|
|
await checkForAppUpdates(_);
|
|
} else if (appService?.hasUpdater === false) {
|
|
checkAppReleaseNotes();
|
|
}
|
|
};
|
|
if (settings.alwaysOnTop) {
|
|
tauriHandleSetAlwaysOnTop(settings.alwaysOnTop);
|
|
}
|
|
doCheckAppUpdates();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [appService?.hasUpdater, settings]);
|
|
|
|
useEffect(() => {
|
|
if (appService?.isMobileApp) {
|
|
lockScreenOrientation({ orientation: 'auto' });
|
|
}
|
|
}, [appService]);
|
|
|
|
const handleRefreshLibrary = useCallback(async () => {
|
|
const appService = await envConfig.getAppService();
|
|
const settings = await appService.loadSettings();
|
|
const library = await appService.loadLibraryBooks();
|
|
setSettings(settings);
|
|
setLibrary(library);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [envConfig, appService]);
|
|
|
|
useEffect(() => {
|
|
if (appService?.hasWindow) {
|
|
const currentWebview = getCurrentWebview();
|
|
const unlisten = currentWebview.listen('close-reader-window', async () => {
|
|
handleRefreshLibrary();
|
|
});
|
|
return () => {
|
|
unlisten.then((fn) => fn());
|
|
};
|
|
}
|
|
return;
|
|
}, [appService, handleRefreshLibrary]);
|
|
|
|
const handleImportBookFiles = useCallback(async (event: CustomEvent) => {
|
|
const selectedFiles: SelectedFile[] = event.detail.files;
|
|
const groupId: string = event.detail.groupId || '';
|
|
if (selectedFiles.length === 0) return;
|
|
await importBooks(selectedFiles, groupId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
eventDispatcher.on('import-book-files', handleImportBookFiles);
|
|
return () => {
|
|
eventDispatcher.off('import-book-files', handleImportBookFiles);
|
|
};
|
|
}, [handleImportBookFiles]);
|
|
|
|
useEffect(() => {
|
|
refreshGroups();
|
|
if (!libraryBooks.some((book) => !book.deletedAt)) {
|
|
handleSetSelectMode(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [libraryBooks]);
|
|
|
|
const processOpenWithFiles = useCallback(
|
|
async (appService: AppService, openWithFiles: string[], libraryBooks: Book[]) => {
|
|
const settings = await appService.loadSettings();
|
|
const bookIds: string[] = [];
|
|
for (const file of openWithFiles) {
|
|
console.log('Open with book:', file);
|
|
try {
|
|
const temp = appService.isMobile ? false : !settings.autoImportBooksOnOpen;
|
|
const book = await appService.importBook(file, libraryBooks, true, true, false, temp);
|
|
if (book) {
|
|
bookIds.push(book.hash);
|
|
}
|
|
} catch (error) {
|
|
console.log('Failed to import book:', file, error);
|
|
}
|
|
}
|
|
setLibrary(libraryBooks);
|
|
appService.saveLibraryBooks(libraryBooks);
|
|
|
|
console.log('Opening books:', bookIds);
|
|
if (bookIds.length > 0) {
|
|
setPendingNavigationBookIds(bookIds);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[],
|
|
);
|
|
|
|
const handleOpenLastBooks = async (
|
|
appService: AppService,
|
|
lastBookIds: string[],
|
|
libraryBooks: Book[],
|
|
) => {
|
|
if (lastBookIds.length === 0) return false;
|
|
const bookIds: string[] = [];
|
|
for (const bookId of lastBookIds) {
|
|
const book = libraryBooks.find((b) => b.hash === bookId);
|
|
if (book && (await appService.isBookAvailable(book))) {
|
|
bookIds.push(book.hash);
|
|
}
|
|
}
|
|
console.log('Opening last books:', bookIds);
|
|
if (bookIds.length > 0) {
|
|
setPendingNavigationBookIds(bookIds);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (pendingNavigationBookIds) {
|
|
const bookIds = pendingNavigationBookIds;
|
|
setPendingNavigationBookIds(null);
|
|
if (bookIds.length > 0) {
|
|
navigateToReader(router, bookIds);
|
|
}
|
|
}
|
|
}, [pendingNavigationBookIds, appService, router]);
|
|
|
|
useEffect(() => {
|
|
if (isInitiating.current) return;
|
|
isInitiating.current = true;
|
|
|
|
const initLogin = async () => {
|
|
const appService = await envConfig.getAppService();
|
|
const settings = await appService.loadSettings();
|
|
if (token && user) {
|
|
if (!settings.keepLogin) {
|
|
settings.keepLogin = true;
|
|
setSettings(settings);
|
|
saveSettings(envConfig, settings);
|
|
}
|
|
} else if (settings.keepLogin) {
|
|
router.push('/auth');
|
|
}
|
|
};
|
|
|
|
const loadingTimeout = setTimeout(() => setLoading(true), 300);
|
|
const initLibrary = async () => {
|
|
const appService = await envConfig.getAppService();
|
|
const settings = await appService.loadSettings();
|
|
setSettings(settings);
|
|
|
|
// Reuse the library from the store when we return from the reader
|
|
const library = libraryBooks.length > 0 ? libraryBooks : await appService.loadLibraryBooks();
|
|
let opened = false;
|
|
if (checkOpenWithBooks) {
|
|
opened = await handleOpenWithBooks(appService, library);
|
|
}
|
|
setCheckOpenWithBooks(opened);
|
|
if (!opened && checkLastOpenBooks && settings.openLastBooks) {
|
|
opened = await handleOpenLastBooks(appService, settings.lastOpenBooks, library);
|
|
}
|
|
setCheckLastOpenBooks(opened);
|
|
|
|
setLibrary(library);
|
|
setLibraryLoaded(true);
|
|
if (loadingTimeout) clearTimeout(loadingTimeout);
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleOpenWithBooks = async (appService: AppService, library: Book[]) => {
|
|
const openWithFiles = (await parseOpenWithFiles()) || [];
|
|
|
|
if (openWithFiles.length > 0) {
|
|
return await processOpenWithFiles(appService, openWithFiles, library);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
initLogin();
|
|
initLibrary();
|
|
return () => {
|
|
setCheckOpenWithBooks(false);
|
|
setCheckLastOpenBooks(false);
|
|
isInitiating.current = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [searchParams]);
|
|
|
|
useEffect(() => {
|
|
const group = searchParams?.get('group') || '';
|
|
const groupName = getGroupName(group);
|
|
setCurrentGroupPath(groupName);
|
|
}, [libraryBooks, searchParams, getGroupName]);
|
|
|
|
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: SelectedFile[], groupId?: string) => {
|
|
setLoading(true);
|
|
const { library } = useLibraryStore.getState();
|
|
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
|
|
const errorMap: [string, string][] = [
|
|
['No chapters detected', _('No chapters detected')],
|
|
['Failed to parse EPUB', _('Failed to parse the EPUB file')],
|
|
['Unsupported format', _('This book format is not supported')],
|
|
['Failed to open file', _('Failed to open the book file')],
|
|
['Invalid or empty book file', _('The book file is empty')],
|
|
['Unsupported or corrupted book file', _('The book file is corrupted')],
|
|
];
|
|
|
|
const processFile = async (selectedFile: SelectedFile) => {
|
|
const file = selectedFile.file || selectedFile.path;
|
|
if (!file) return;
|
|
try {
|
|
const book = await appService?.importBook(file, library);
|
|
if (book && groupId) {
|
|
book.groupId = groupId;
|
|
book.groupName = getGroupName(groupId);
|
|
await updateBook(envConfig, book);
|
|
}
|
|
if (user && book && !book.uploadedAt && settings.autoUpload) {
|
|
console.log('Uploading book:', book.title);
|
|
handleBookUpload(book, false);
|
|
}
|
|
} catch (error) {
|
|
const filename = typeof file === 'string' ? file : file.name;
|
|
const baseFilename = getFilename(filename);
|
|
const errorMessage =
|
|
error instanceof Error
|
|
? errorMap.find(([str]) => error.message.includes(str))?.[1] || error.message
|
|
: '';
|
|
failedImports.push({ filename: baseFilename, errorMessage });
|
|
console.error('Failed to import book:', filename, error);
|
|
}
|
|
};
|
|
|
|
const concurrency = 4;
|
|
for (let i = 0; i < files.length; i += concurrency) {
|
|
const batch = files.slice(i, i + concurrency);
|
|
await Promise.all(batch.map(processFile));
|
|
}
|
|
pushLibrary();
|
|
|
|
if (failedImports.length > 0) {
|
|
const filenames = failedImports.map((f) => f.filename);
|
|
const errorMessage = failedImports.find((f) => f.errorMessage)?.errorMessage || '';
|
|
|
|
eventDispatcher.dispatch('toast', {
|
|
message:
|
|
_('Failed to import book(s): {{filenames}}', {
|
|
filenames: listFormater(false).format(filenames),
|
|
}) + (errorMessage ? `\n${errorMessage}` : ''),
|
|
type: 'error',
|
|
});
|
|
}
|
|
|
|
setLibrary([...library]);
|
|
appService?.saveLibraryBooks(library);
|
|
setLoading(false);
|
|
};
|
|
|
|
const updateBookTransferProgress = throttle((bookHash: string, progress: ProgressPayload) => {
|
|
if (progress.total === 0) return;
|
|
const progressPct = (progress.progress / progress.total) * 100;
|
|
setBooksTransferProgress((prev) => ({
|
|
...prev,
|
|
[bookHash]: progressPct,
|
|
}));
|
|
}, 500);
|
|
|
|
const handleBookUpload = useCallback(
|
|
async (book: Book, syncBooks = true) => {
|
|
try {
|
|
await appService?.uploadBook(book, (progress) => {
|
|
updateBookTransferProgress(book.hash, progress);
|
|
});
|
|
setBooksTransferProgress((prev) => {
|
|
const updated = { ...prev };
|
|
delete updated[book.hash];
|
|
return updated;
|
|
});
|
|
await updateBook(envConfig, book);
|
|
if (syncBooks) pushLibrary();
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'info',
|
|
timeout: 2000,
|
|
message: _('Book uploaded: {{title}}', {
|
|
title: book.title,
|
|
}),
|
|
});
|
|
return true;
|
|
} catch (err) {
|
|
setBooksTransferProgress((prev) => {
|
|
const updated = { ...prev };
|
|
delete updated[book.hash];
|
|
return updated;
|
|
});
|
|
if (err instanceof Error) {
|
|
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
|
settings.keepLogin = false;
|
|
setSettings(settings);
|
|
navigateToLogin(router);
|
|
return false;
|
|
} else if (err.message.includes('Insufficient storage quota')) {
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'error',
|
|
message: _('Insufficient storage quota'),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'error',
|
|
message: _('Failed to upload book: {{title}}', {
|
|
title: book.title,
|
|
}),
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[appService],
|
|
);
|
|
|
|
const handleBookDownload = useCallback(
|
|
async (book: Book, redownload = false) => {
|
|
try {
|
|
await appService?.downloadBook(book, false, redownload, (progress) => {
|
|
updateBookTransferProgress(book.hash, progress);
|
|
});
|
|
await updateBook(envConfig, book);
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'info',
|
|
timeout: 2000,
|
|
message: _('Book downloaded: {{title}}', {
|
|
title: book.title,
|
|
}),
|
|
});
|
|
return true;
|
|
} catch {
|
|
eventDispatcher.dispatch('toast', {
|
|
message: _('Failed to download book: {{title}}', {
|
|
title: book.title,
|
|
}),
|
|
type: 'error',
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[appService],
|
|
);
|
|
|
|
const handleBookDelete = (deleteAction: DeleteAction) => {
|
|
return async (book: Book, syncBooks = true) => {
|
|
const deletionMessages = {
|
|
both: _('Book deleted: {{title}}', { title: book.title }),
|
|
cloud: _('Deleted cloud backup of the book: {{title}}', { title: book.title }),
|
|
local: _('Deleted local copy of the book: {{title}}', { title: book.title }),
|
|
};
|
|
const deletionFailMessages = {
|
|
both: _('Failed to delete book: {{title}}', { title: book.title }),
|
|
cloud: _('Failed to delete cloud backup of the book: {{title}}', { title: book.title }),
|
|
local: _('Failed to delete local copy of the book: {{title}}', { title: book.title }),
|
|
};
|
|
try {
|
|
await appService?.deleteBook(book, deleteAction);
|
|
await updateBook(envConfig, book);
|
|
if (syncBooks) pushLibrary();
|
|
eventDispatcher.dispatch('toast', {
|
|
type: 'info',
|
|
timeout: 2000,
|
|
message: deletionMessages[deleteAction],
|
|
});
|
|
return true;
|
|
} catch {
|
|
eventDispatcher.dispatch('toast', {
|
|
message: deletionFailMessages[deleteAction],
|
|
type: 'error',
|
|
});
|
|
return false;
|
|
}
|
|
};
|
|
};
|
|
|
|
const handleUpdateMetadata = async (book: Book, metadata: BookMetadata) => {
|
|
book.metadata = metadata;
|
|
book.title = formatTitle(metadata.title);
|
|
book.author = formatAuthors(metadata.author);
|
|
book.primaryLanguage = getPrimaryLanguage(metadata.language);
|
|
book.updatedAt = Date.now();
|
|
if (metadata.coverImageBlobUrl || metadata.coverImageUrl || metadata.coverImageFile) {
|
|
book.coverImageUrl = metadata.coverImageBlobUrl || metadata.coverImageUrl;
|
|
try {
|
|
await appService?.updateCoverImage(
|
|
book,
|
|
metadata.coverImageBlobUrl || metadata.coverImageUrl,
|
|
metadata.coverImageFile,
|
|
);
|
|
} catch (error) {
|
|
console.warn('Failed to update cover image:', error);
|
|
}
|
|
}
|
|
if (isWebAppPlatform()) {
|
|
// Clear HTTP cover image URL if cover is updated with a local file
|
|
if (metadata.coverImageBlobUrl) {
|
|
metadata.coverImageUrl = undefined;
|
|
}
|
|
} else {
|
|
metadata.coverImageUrl = undefined;
|
|
}
|
|
metadata.coverImageBlobUrl = undefined;
|
|
metadata.coverImageFile = undefined;
|
|
await updateBook(envConfig, book);
|
|
};
|
|
|
|
const handleImportBooks = async () => {
|
|
setIsSelectMode(false);
|
|
console.log('Importing books...');
|
|
selectFiles({ type: 'books', multiple: true }).then((result) => {
|
|
if (result.files.length === 0 || result.error) return;
|
|
const groupId = searchParams?.get('group') || '';
|
|
importBooks(result.files, groupId);
|
|
});
|
|
};
|
|
|
|
const handleSetSelectMode = (selectMode: boolean) => {
|
|
if (selectMode && appService?.hasHaptics) {
|
|
impactFeedback('medium');
|
|
}
|
|
setIsSelectMode(selectMode);
|
|
setIsSelectAll(false);
|
|
setIsSelectNone(false);
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
setIsSelectAll(true);
|
|
setIsSelectNone(false);
|
|
};
|
|
|
|
const handleDeselectAll = () => {
|
|
setIsSelectNone(true);
|
|
setIsSelectAll(false);
|
|
};
|
|
|
|
const handleShowDetailsBook = (book: Book) => {
|
|
setShowDetailsBook(book);
|
|
};
|
|
|
|
const handleNavigateToPath = (path: string | undefined) => {
|
|
const group = path ? getGroupId(path) : '';
|
|
const params = new URLSearchParams(searchParams?.toString());
|
|
if (group) {
|
|
params.set('group', group);
|
|
} else {
|
|
params.delete('group');
|
|
}
|
|
navigateToLibrary(router, `${params.toString()}`);
|
|
setTimeout(() => {
|
|
setCurrentGroupPath(path);
|
|
}, 300);
|
|
};
|
|
|
|
if (!appService || !insets || checkOpenWithBooks || checkLastOpenBooks) {
|
|
return <div className={clsx('h-[100vh]', !appService?.isLinuxApp && 'bg-base-200')} />;
|
|
}
|
|
|
|
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
|
|
|
|
return (
|
|
<div
|
|
ref={pageRef}
|
|
aria-label='Your Library'
|
|
className={clsx(
|
|
'library-page text-base-content flex h-[100vh] select-none flex-col overflow-hidden',
|
|
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
|
|
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
|
)}
|
|
>
|
|
<div
|
|
className='relative top-0 z-40 w-full'
|
|
role='banner'
|
|
tabIndex={-1}
|
|
aria-label={_('Library Header')}
|
|
>
|
|
<LibraryHeader
|
|
isSelectMode={isSelectMode}
|
|
isSelectAll={isSelectAll}
|
|
onImportBooks={handleImportBooks}
|
|
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
|
onSelectAll={handleSelectAll}
|
|
onDeselectAll={handleDeselectAll}
|
|
/>
|
|
<progress
|
|
className={clsx(
|
|
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
|
|
isSyncing ? 'opacity-100' : 'opacity-0',
|
|
)}
|
|
value={syncProgress * 100}
|
|
max='100'
|
|
/>
|
|
</div>
|
|
{(loading || isSyncing) && (
|
|
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
|
<Spinner loading />
|
|
</div>
|
|
)}
|
|
{currentGroupPath && (
|
|
<div
|
|
className={`transition-all duration-300 ease-in-out ${
|
|
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
|
|
}`}
|
|
>
|
|
<div className='flex flex-wrap items-center gap-y-1 px-4 text-base'>
|
|
<button
|
|
onClick={() => handleNavigateToPath(undefined)}
|
|
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
|
|
>
|
|
{_('All')}
|
|
</button>
|
|
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
|
|
const isLast = index === array.length - 1;
|
|
return (
|
|
<React.Fragment key={index}>
|
|
<MdChevronRight size={iconSize} className='text-neutral-content' />
|
|
{isLast ? (
|
|
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
|
|
) : (
|
|
<button
|
|
onClick={() => handleNavigateToPath(crumb.path)}
|
|
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
|
|
>
|
|
{crumb.name}
|
|
</button>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showBookshelf &&
|
|
(libraryBooks.some((book) => !book.deletedAt) ? (
|
|
<OverlayScrollbarsComponent
|
|
defer
|
|
aria-label=''
|
|
ref={osRef}
|
|
className='flex-grow'
|
|
options={{ scrollbars: { autoHide: 'scroll' } }}
|
|
events={{
|
|
initialized: (instance) => {
|
|
const { content } = instance.elements();
|
|
if (content) {
|
|
containerRef.current = content as HTMLDivElement;
|
|
}
|
|
},
|
|
}}
|
|
>
|
|
<div
|
|
className={clsx('scroll-container drop-zone flex-grow', isDragging && 'drag-over')}
|
|
style={{
|
|
paddingTop: '0px',
|
|
paddingRight: `${insets.right}px`,
|
|
paddingBottom: `${insets.bottom}px`,
|
|
paddingLeft: `${insets.left}px`,
|
|
}}
|
|
>
|
|
<DropIndicator />
|
|
<Bookshelf
|
|
libraryBooks={libraryBooks}
|
|
isSelectMode={isSelectMode}
|
|
isSelectAll={isSelectAll}
|
|
isSelectNone={isSelectNone}
|
|
handleImportBooks={handleImportBooks}
|
|
handleBookUpload={handleBookUpload}
|
|
handleBookDownload={handleBookDownload}
|
|
handleBookDelete={handleBookDelete('both')}
|
|
handleSetSelectMode={handleSetSelectMode}
|
|
handleShowDetailsBook={handleShowDetailsBook}
|
|
booksTransferProgress={booksTransferProgress}
|
|
handlePushLibrary={pushLibrary}
|
|
/>
|
|
</div>
|
|
</OverlayScrollbarsComponent>
|
|
) : (
|
|
<div className='hero drop-zone h-screen items-center justify-center'>
|
|
<DropIndicator />
|
|
<div className='hero-content text-neutral-content text-center'>
|
|
<div className='max-w-md'>
|
|
<h1 className='mb-5 text-5xl font-bold'>{_('Your Library')}</h1>
|
|
<p className='mb-5'>
|
|
{_(
|
|
'Welcome to your library. You can import your books here and read them anytime.',
|
|
)}
|
|
</p>
|
|
<button className='btn btn-primary rounded-xl' onClick={handleImportBooks}>
|
|
{_('Import Books')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{showDetailsBook && (
|
|
<BookDetailModal
|
|
isOpen={!!showDetailsBook}
|
|
book={showDetailsBook}
|
|
onClose={() => setShowDetailsBook(null)}
|
|
handleBookUpload={handleBookUpload}
|
|
handleBookDownload={handleBookDownload}
|
|
handleBookDelete={handleBookDelete('both')}
|
|
handleBookDeleteCloudBackup={handleBookDelete('cloud')}
|
|
handleBookDeleteLocalCopy={handleBookDelete('local')}
|
|
handleBookMetadataUpdate={handleUpdateMetadata}
|
|
/>
|
|
)}
|
|
<AboutWindow />
|
|
<UpdaterWindow />
|
|
<MigrateDataWindow />
|
|
{isSettingsDialogOpen && <SettingsDialog bookKey={''} />}
|
|
<Toast />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const LibraryPage = () => {
|
|
return (
|
|
<Suspense fallback={<div className='h-[100vh]' />}>
|
|
<LibraryPageWithSearchParams />
|
|
</Suspense>
|
|
);
|
|
};
|
|
|
|
export default LibraryPage;
|