fix(library): support dropping directories to import books (#4068)
Drag-and-drop now classifies dropped items into files vs directories. Real files keep the existing import flow; dropped directories reuse handleImportBooksFromDirectory via a new import-book-directory event, matching the "From Directory" menu behavior instead of failing with "No supported files found". Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,14 @@ import { SelectedFile } from '@/hooks/useFileSelector';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { BOOK_ACCEPT_FORMATS } from '@/services/constants';
|
||||
import { BOOK_ACCEPT_FORMATS, SUPPORTED_BOOK_EXTS } from '@/services/constants';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
const hasSupportedBookExt = (name: string) => {
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
return ext ? SUPPORTED_BOOK_EXTS.includes(ext) : false;
|
||||
};
|
||||
|
||||
export const useDragDropImport = () => {
|
||||
const _ = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -17,18 +22,27 @@ export const useDragDropImport = () => {
|
||||
const { appService } = useEnv();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDroppedFiles = async (files: File[] | string[]) => {
|
||||
if (files.length === 0) return;
|
||||
const supportedFiles = files.filter((file) => {
|
||||
let fileExt;
|
||||
if (typeof file === 'string') {
|
||||
fileExt = file.split('.').pop()?.toLowerCase();
|
||||
const handleDroppedFiles = async (droppedItems: File[] | string[]) => {
|
||||
if (droppedItems.length === 0 || !appService) return;
|
||||
|
||||
const fileItems: (File | string)[] = [];
|
||||
const directoryPaths: string[] = [];
|
||||
for (const item of droppedItems) {
|
||||
if (typeof item === 'string' && (await appService.isDirectory(item, 'None'))) {
|
||||
directoryPaths.push(item);
|
||||
} else {
|
||||
fileExt = file.name.split('.').pop()?.toLowerCase();
|
||||
fileItems.push(item);
|
||||
}
|
||||
return BOOK_ACCEPT_FORMATS.includes(`.${fileExt}`);
|
||||
});
|
||||
if (supportedFiles.length === 0) {
|
||||
}
|
||||
|
||||
const fileSelections: SelectedFile[] = fileItems
|
||||
.filter((item) => hasSupportedBookExt(typeof item === 'string' ? item : item.name))
|
||||
.map((item) => ({
|
||||
file: typeof item === 'string' ? undefined : item,
|
||||
path: typeof item === 'string' ? item : undefined,
|
||||
}));
|
||||
|
||||
if (fileSelections.length === 0 && directoryPaths.length === 0) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message: _('No supported files found. Supported formats: {{formats}}', {
|
||||
formats: BOOK_ACCEPT_FORMATS,
|
||||
@@ -38,18 +52,19 @@ export const useDragDropImport = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appService?.hasHaptics) {
|
||||
if (appService.hasHaptics) {
|
||||
impactFeedback('medium');
|
||||
}
|
||||
|
||||
const selectedFiles = supportedFiles.map(
|
||||
(file) =>
|
||||
({
|
||||
file: typeof file === 'string' ? undefined : file,
|
||||
path: typeof file === 'string' ? file : undefined,
|
||||
}) as SelectedFile,
|
||||
);
|
||||
eventDispatcher.dispatch('import-book-files', { files: selectedFiles, groupId: group });
|
||||
if (fileSelections.length > 0) {
|
||||
eventDispatcher.dispatch('import-book-files', {
|
||||
files: fileSelections,
|
||||
groupId: group,
|
||||
});
|
||||
}
|
||||
for (const dir of directoryPaths) {
|
||||
eventDispatcher.dispatch('import-book-directory', { path: dir });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement> | DragEvent) => {
|
||||
|
||||
@@ -333,12 +333,21 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleImportBookDirectory = useCallback(async (event: CustomEvent) => {
|
||||
const dirPath: string | undefined = event.detail?.path;
|
||||
if (!dirPath) return;
|
||||
await handleImportBooksFromDirectory(dirPath);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
eventDispatcher.on('import-book-files', handleImportBookFiles);
|
||||
eventDispatcher.on('import-book-directory', handleImportBookDirectory);
|
||||
return () => {
|
||||
eventDispatcher.off('import-book-files', handleImportBookFiles);
|
||||
eventDispatcher.off('import-book-directory', handleImportBookDirectory);
|
||||
};
|
||||
}, [handleImportBookFiles]);
|
||||
}, [handleImportBookFiles, handleImportBookDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryBooks.some((book) => !book.deletedAt)) {
|
||||
@@ -802,19 +811,21 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
});
|
||||
};
|
||||
|
||||
const handleImportBooksFromDirectory = async () => {
|
||||
const handleImportBooksFromDirectory = async (dirPath?: string) => {
|
||||
if (!appService || !isTauriAppPlatform()) return;
|
||||
|
||||
setIsSelectMode(false);
|
||||
console.log('Importing books from directory...');
|
||||
let importDirectory: string | undefined = '';
|
||||
if (appService.isAndroidApp) {
|
||||
if (!(await requestStoragePermission())) return;
|
||||
const response = await selectDirectory();
|
||||
importDirectory = response.path;
|
||||
} else {
|
||||
const selectedDir = await appService.selectDirectory?.('read');
|
||||
importDirectory = selectedDir;
|
||||
let importDirectory: string | undefined = dirPath;
|
||||
if (!importDirectory) {
|
||||
if (appService.isAndroidApp) {
|
||||
if (!(await requestStoragePermission())) return;
|
||||
const response = await selectDirectory();
|
||||
importDirectory = response.path;
|
||||
} else {
|
||||
const selectedDir = await appService.selectDirectory?.('read');
|
||||
importDirectory = selectedDir;
|
||||
}
|
||||
}
|
||||
if (!importDirectory) {
|
||||
console.log('No directory selected');
|
||||
|
||||
@@ -158,6 +158,15 @@ export abstract class BaseAppService implements AppService {
|
||||
return await this.fs.exists(path, base);
|
||||
}
|
||||
|
||||
async isDirectory(path: string, base: BaseDir): Promise<boolean> {
|
||||
try {
|
||||
const info = await this.fs.stats(path, base);
|
||||
return info.isDirectory;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getImageURL(path: string): Promise<string> {
|
||||
return await this.fs.getImageURL(path);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface AppService {
|
||||
deleteFile(path: string, base: BaseDir): Promise<void>;
|
||||
deleteDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
|
||||
exists(path: string, base: BaseDir): Promise<boolean>;
|
||||
isDirectory(path: string, base: BaseDir): Promise<boolean>;
|
||||
getImageURL(path: string): Promise<string>;
|
||||
|
||||
setCustomRootDir(customRootDir: string): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user