Files
Foxel/web/src/pages/FileExplorerPage/FileExplorerPage.tsx

489 lines
18 KiB
TypeScript

import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { theme, Pagination } from 'antd';
import { useFileExplorer } from './hooks/useFileExplorer';
import { useFileSelection } from './hooks/useFileSelection';
import { useFileActions } from './hooks/useFileActions.tsx';
import { useAppWindows } from '../../contexts/AppWindowsContext';
import { useContextMenu } from './hooks/useContextMenu';
import { useProcessor } from './hooks/useProcessor';
import { useFileSearch } from './hooks/useFileSearch';
import { useThumbnails } from './hooks/useThumbnails';
import { useUploader } from './hooks/useUploader';
import { Header } from './components/Header';
import { GridView } from './components/GridView';
import { FileListView } from './components/FileListView';
import { EmptyState } from './components/EmptyState';
import { ContextMenu } from './components/ContextMenu';
import { DropzoneOverlay } from './components/DropzoneOverlay';
import { CreateDirModal } from './components/Modals/CreateDirModal';
import { CreateFileModal } from './components/Modals/CreateFileModal';
import { RenameModal } from './components/Modals/RenameModal';
import { ProcessorModal } from './components/Modals/ProcessorModal';
import UploadModal from './components/Modals/UploadModal';
import { ShareModal } from './components/Modals/ShareModal';
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
import { FileDetailModal } from './components/FileDetailModal';
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
import { SearchResultsView } from './components/SearchResultsView';
import type { ViewMode } from './types';
import { vfsApi, type VfsEntry } from '../../api/client';
import { LoadingSkeleton } from './components/LoadingSkeleton';
import useResponsive from '../../hooks/useResponsive';
const FileExplorerPage = memo(function FileExplorerPage() {
const { navKey = 'files', '*': restPath = '' } = useParams();
const { token } = theme.useToken();
const { isMobile } = useResponsive();
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [isDragging, setIsDragging] = useState(false);
const [showSkeleton, setShowSkeleton] = useState(false);
const dragCounter = useRef(0);
const skeletonTimerRef = useRef<number | null>(null);
// --- Hooks ---
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, openContextMenuAt, closeContextMenus } = useContextMenu();
const uploader = useUploader(path, refresh);
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
const { thumbs } = useThumbnails(entries, path);
// --- State for Modals ---
const [creatingDir, setCreatingDir] = useState(false);
const [creatingFile, setCreatingFile] = useState(false);
const [renaming, setRenaming] = useState<VfsEntry | null>(null);
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
// --- Search ---
const fileSearch = useFileSearch({
currentPath: path,
navigateTo,
openFileWithDefaultApp,
openContextMenu,
closeContextMenus,
activeEntry: ctxMenu?.entry ?? null,
});
const {
isSearching,
actionPath,
loading: searchLoading,
mode: searchMode,
query: searchQuery,
page: searchPage,
pageSize: searchPageSize,
displayItems: searchItems,
selectedPaths: searchSelectedPaths,
selectedNames: searchSelectedNames,
contextEntries: searchContextEntries,
entrySnapshot: searchEntrySnapshot,
showPagination: showSearchPagination,
totalItems: searchTotalItems,
clearSearchParams,
updateSearchPage,
refreshSearch,
openResult: openSearchResult,
selectResult: selectSearchResult,
openResultContextMenu: openSearchContextMenu,
openResultContextMenuAt: openSearchContextMenuAt,
clearSelection: clearSearchSelection,
} = fileSearch;
const entryBasePath = isSearching ? actionPath : path;
// --- Effects ---
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
useEffect(() => {
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
useEffect(() => {
if (isMobile && viewMode !== 'grid') {
setViewMode('grid');
}
}, [isMobile, viewMode]);
const effectiveRefresh = useCallback(() => {
if (isSearching) {
refreshSearch();
return;
}
refresh();
}, [isSearching, refresh, refreshSearch]);
useEffect(() => {
if (skeletonTimerRef.current !== null) {
clearTimeout(skeletonTimerRef.current);
skeletonTimerRef.current = null;
}
if (loading) {
skeletonTimerRef.current = window.setTimeout(() => {
setShowSkeleton(true);
skeletonTimerRef.current = null;
}, 200);
} else {
setShowSkeleton(false);
}
return () => {
if (skeletonTimerRef.current !== null) {
clearTimeout(skeletonTimerRef.current);
skeletonTimerRef.current = null;
}
};
}, [loading]);
// --- Handlers ---
const clearAllSelection = useCallback(() => {
clearSelection();
clearSearchSelection();
}, [clearSearchSelection, clearSelection]);
const { doCreateDir: doCreateDirInCurrentDir, doCreateFile: doCreateFileInCurrentDir } = useFileActions({
path,
refresh,
clearSelection,
onShare: (entriesToShare) => setSharingEntries(entriesToShare),
onGetDirectLink: (entry) => setDirectLinkEntry(entry),
});
const { doDelete, doRename, doDownload, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({
path: entryBasePath,
refresh: effectiveRefresh,
clearSelection: clearAllSelection,
onShare: (entriesToShare) => setSharingEntries(entriesToShare),
onGetDirectLink: (entry) => setDirectLinkEntry(entry),
});
const processorHook = useProcessor({ path: entryBasePath, processorTypes, refresh: effectiveRefresh });
const handleOpenEntry = (entry: VfsEntry) => {
if (entry.is_dir) {
const next = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
navigateTo(next.replace(/\/+/g, '/'));
} else {
openFileWithDefaultApp(entry, entryBasePath);
}
};
const openDetail = async (entry: VfsEntry) => {
setDetailEntry(entry);
setDetailLoading(true);
try {
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
const stat = await vfsApi.stat(fullPath, { verbose: true });
setDetailData(stat as Record<string, unknown>);
} catch (error) {
const messageText = error instanceof Error ? error.message : String(error);
setDetailData({ error: messageText });
} finally {
setDetailLoading(false);
}
};
const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
if (!targetEntries || targetEntries.length === 0) return '';
if (targetEntries.length > 1) {
return entryBasePath || '/';
}
const entry = targetEntries[0];
const base = entryBasePath === '/' ? '' : entryBasePath;
const segments = [base, entry.name].filter(Boolean);
const joined = segments.join('/');
if (!joined) {
return '/';
}
return joined.startsWith('/') ? joined : `/${joined}`;
}, [entryBasePath]);
const showFsPagination = !isSearching && pagination.total > 0;
const shouldReserveBottomBar = showSearchPagination || showFsPagination;
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
void handleFileDrop(e.dataTransfer);
};
const getAnchorPoint = useCallback((anchor: HTMLElement) => {
const rect = anchor.getBoundingClientRect();
return {
x: Math.min(rect.right, window.innerWidth - 24),
y: Math.min(rect.bottom + 8, window.innerHeight - 24),
};
}, []);
const openEntryMenuFromAnchor = useCallback((entry: VfsEntry, anchor: HTMLElement) => {
const point = getAnchorPoint(anchor);
openContextMenuAt(entry, point.x, point.y);
}, [getAnchorPoint, openContextMenuAt]);
const openSearchMenuFromAnchor = useCallback((fullPath: string, anchor: HTMLElement) => {
const point = getAnchorPoint(anchor);
void openSearchContextMenuAt(point.x, point.y, fullPath);
}, [getAnchorPoint, openSearchContextMenuAt]);
return (
<div
style={{
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadius,
height: '100%',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}
onClick={closeContextMenus}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<Header
navKey={navKey}
path={path}
loading={isSearching ? searchLoading : loading}
viewMode={viewMode}
sortBy={sortBy}
sortOrder={sortOrder}
isMobile={isMobile}
onGoUp={goUp}
onNavigate={navigateTo}
onRefresh={effectiveRefresh}
onCreateDir={() => setCreatingDir(true)}
onCreateFile={() => setCreatingFile(true)}
onUploadFile={openFilePicker}
onUploadDirectory={openDirectoryPicker}
onSetViewMode={setViewMode}
onSortChange={handleSortChange}
/>
<input
ref={uploader.fileInputRef}
type="file"
style={{ display: 'none' }}
multiple
onChange={handleFileInputChange}
/>
<input
ref={uploader.directoryInputRef}
type="file"
style={{ display: 'none' }}
multiple
onChange={handleDirectoryInputChange}
/>
<div style={{ flex: 1, overflow: 'auto', minHeight: 0, paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={isMobile ? undefined : openBlankContextMenu}>
{isSearching ? (
<SearchResultsView
viewMode={viewMode}
loading={searchLoading}
mode={searchMode}
query={searchQuery}
items={searchItems}
selectedPaths={searchSelectedPaths}
entrySnapshot={searchEntrySnapshot}
mobile={isMobile}
onClearSearch={clearSearchParams}
onSelect={selectSearchResult}
onOpen={(fullPath) => { void openSearchResult(fullPath); }}
onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }}
onOpenMenu={openSearchMenuFromAnchor}
/>
) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
<LoadingSkeleton mode={viewMode} />
) : !loading && entries.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
) : viewMode === 'grid' ? (
<GridView
entries={entries}
thumbs={thumbs}
selectedEntries={selectedEntries}
path={path}
mobile={isMobile}
onSelect={handleSelect}
onSelectRange={handleSelectRange}
onOpen={handleOpenEntry}
onContextMenu={openContextMenu}
onOpenMenu={openEntryMenuFromAnchor}
/>
) : (
<FileListView
entries={entries}
selectedEntries={selectedEntries}
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
onSelectionChange={setSelectedEntries}
onOpen={handleOpenEntry}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
onRename={setRenaming}
onDelete={(entry) => doDelete([entry])}
onContextMenu={openContextMenu}
/>
)}
</div>
{showFsPagination && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
<Pagination {...pagination} onChange={handlePaginationChange} onShowSizeChange={handlePaginationChange} />
</div>
)}
{showSearchPagination && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
<Pagination
current={searchPage}
pageSize={searchPageSize}
total={Math.max(searchTotalItems, 1)}
showSizeChanger={false}
size="small"
onChange={(nextPage) => updateSearchPage(nextPage)}
/>
</div>
)}
{/* --- Modals & Context Menus --- */}
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDirInCurrentDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
<CreateFileModal open={creatingFile} onOk={(name) => { doCreateFileInCurrentDir(name); setCreatingFile(false); }} onCancel={() => setCreatingFile(false)} />
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
<MoveCopyModal
mode="move"
entries={movingEntries}
open={movingEntries.length > 0}
defaultPath={buildDefaultDestination(movingEntries)}
onOk={async (destination) => {
if (movingEntries.length > 0) {
await doMove(movingEntries, destination);
}
}}
onCancel={() => setMovingEntries([])}
/>
<MoveCopyModal
mode="copy"
entries={copyingEntries}
open={copyingEntries.length > 0}
defaultPath={buildDefaultDestination(copyingEntries)}
onOk={async (destination) => {
if (copyingEntries.length > 0) {
await doCopy(copyingEntries, destination);
}
}}
onCancel={() => setCopyingEntries([])}
/>
{sharingEntries.length > 0 && (
<ShareModal
path={entryBasePath}
entries={sharingEntries}
open={sharingEntries.length > 0}
onOk={() => setSharingEntries([])}
onCancel={() => setSharingEntries([])}
/>
)}
<DirectLinkModal
entry={directLinkEntry}
path={entryBasePath}
open={!!directLinkEntry}
onCancel={() => setDirectLinkEntry(null)}
/>
<ProcessorModal
entry={processorHook.processorModal.entry}
visible={processorHook.processorModal.visible}
loading={processorHook.processorLoading}
processorTypes={processorTypes}
selectedProcessor={processorHook.selectedProcessor}
config={processorHook.processorConfig}
savingPath={processorHook.processorSavingPath}
overwrite={processorHook.processorOverwrite}
onOk={processorHook.handleProcessorOk}
onCancel={processorHook.handleProcessorCancel}
onSelectedProcessorChange={processorHook.setSelectedProcessor}
onConfigChange={processorHook.setProcessorConfig}
onSavingPathChange={processorHook.setProcessorSavingPath}
onOverwriteChange={processorHook.setProcessorOverwrite}
/>
{(ctxMenu || blankCtxMenu) && (
<ContextMenu
x={ctxMenu?.x || blankCtxMenu!.x}
y={ctxMenu?.y || blankCtxMenu!.y}
mobile={isMobile}
entry={ctxMenu?.entry}
entries={isSearching ? searchContextEntries : entries}
selectedEntries={isSearching ? searchSelectedNames : selectedEntries}
processorTypes={processorTypes}
onClose={closeContextMenus}
onOpen={handleOpenEntry}
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, entryBasePath)}
onDownload={doDownload}
onRename={setRenaming}
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
onDetail={openDetail}
onProcess={(entry, type) => {
processorHook.setSelectedProcessor(type);
processorHook.openProcessorModal(entry);
}}
onUploadFile={openFilePicker}
onUploadDirectory={openDirectoryPicker}
onCreateFile={() => setCreatingFile(true)}
onCreateDir={() => setCreatingDir(true)}
onShare={doShare}
onGetDirectLink={doGetDirectLink}
onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}
/>
)}
<UploadModal
visible={uploader.isModalVisible}
files={uploader.files}
isUploading={uploader.isUploading}
totalProgress={uploader.totalProgress}
totalFileBytes={uploader.totalFileBytes}
uploadedFileBytes={uploader.uploadedFileBytes}
conflict={uploader.conflict}
onClose={uploader.closeModal}
onStartUpload={uploader.startUpload}
onResolveConflict={uploader.confirmConflict}
/>
<DropzoneOverlay visible={isDragging} />
</div>
);
});
export default FileExplorerPage;