mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-05 23:57:19 +08:00
feat: enhance file upload functionality
This commit is contained in:
@@ -127,6 +127,8 @@ export const en = {
|
|||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
'Upload File': 'Upload File',
|
'Upload File': 'Upload File',
|
||||||
|
'Upload Files': 'Upload Files',
|
||||||
|
'Upload Folder': 'Upload Folder',
|
||||||
'Open': 'Open',
|
'Open': 'Open',
|
||||||
'Open With': 'Open With',
|
'Open With': 'Open With',
|
||||||
'Default': 'Default',
|
'Default': 'Default',
|
||||||
@@ -137,6 +139,31 @@ export const en = {
|
|||||||
'Details': 'Details',
|
'Details': 'Details',
|
||||||
'Get Direct Link': 'Get Direct Link',
|
'Get Direct Link': 'Get Direct Link',
|
||||||
|
|
||||||
|
// Upload modal
|
||||||
|
'Total progress': 'Total progress',
|
||||||
|
'Upload bytes summary': '{uploaded} / {total}',
|
||||||
|
'Upload task summary': 'Tasks: {completed} / {total} completed, {pending} pending, {failures} failed',
|
||||||
|
'Overwrite confirmation required': 'Overwrite confirmation required',
|
||||||
|
'Target already exists: {path}': 'Target already exists: {path}',
|
||||||
|
'Overwrite': 'Overwrite',
|
||||||
|
'Skip': 'Skip',
|
||||||
|
'Overwrite All': 'Overwrite All',
|
||||||
|
'Skip All': 'Skip All',
|
||||||
|
'Directory': 'Directory',
|
||||||
|
'Creating directory...': 'Creating directory...',
|
||||||
|
'Directory ready': 'Directory ready',
|
||||||
|
'Create directory failed': 'Create directory failed',
|
||||||
|
'Waiting to create': 'Waiting to create',
|
||||||
|
'Waiting for overwrite decision': 'Waiting for overwrite decision',
|
||||||
|
'Waiting to upload': 'Waiting to upload',
|
||||||
|
'Skipped': 'Skipped',
|
||||||
|
'Upload succeeded': 'Upload succeeded',
|
||||||
|
'Upload failed': 'Upload failed',
|
||||||
|
'No items selected for upload': 'No items selected for upload',
|
||||||
|
'No uploadable files or directories found': 'No uploadable files or directories found',
|
||||||
|
'Missing file content': 'Missing file content',
|
||||||
|
'Directory conflicts with existing file': 'A file with the same name already exists at the target location',
|
||||||
|
|
||||||
// Side nav modals
|
// Side nav modals
|
||||||
'Join Community': 'Join Community',
|
'Join Community': 'Join Community',
|
||||||
'Scan to join WeChat group': 'Scan to join WeChat group',
|
'Scan to join WeChat group': 'Scan to join WeChat group',
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export const zh = {
|
|||||||
|
|
||||||
// Context menu
|
// Context menu
|
||||||
'Upload File': '上传文件',
|
'Upload File': '上传文件',
|
||||||
|
'Upload Files': '上传文件',
|
||||||
|
'Upload Folder': '上传文件夹',
|
||||||
'Open': '打开',
|
'Open': '打开',
|
||||||
'Open With': '打开方式',
|
'Open With': '打开方式',
|
||||||
'Default': '默认',
|
'Default': '默认',
|
||||||
@@ -139,6 +141,31 @@ export const zh = {
|
|||||||
'Details': '详情',
|
'Details': '详情',
|
||||||
'Get Direct Link': '获取直链',
|
'Get Direct Link': '获取直链',
|
||||||
|
|
||||||
|
// Upload modal
|
||||||
|
'Total progress': '总体进度',
|
||||||
|
'Upload bytes summary': '{uploaded} / {total}',
|
||||||
|
'Upload task summary': '任务:已完成 {completed} / {total},待处理 {pending},失败 {failures}',
|
||||||
|
'Overwrite confirmation required': '需要确认是否覆盖',
|
||||||
|
'Target already exists: {path}': '目标已存在:{path}',
|
||||||
|
'Overwrite': '覆盖',
|
||||||
|
'Skip': '跳过',
|
||||||
|
'Overwrite All': '全部覆盖',
|
||||||
|
'Skip All': '全部跳过',
|
||||||
|
'Directory': '目录',
|
||||||
|
'Creating directory...': '正在创建目录...',
|
||||||
|
'Directory ready': '目录已就绪',
|
||||||
|
'Create directory failed': '创建目录失败',
|
||||||
|
'Waiting to create': '等待创建',
|
||||||
|
'Waiting for overwrite decision': '等待覆盖处理',
|
||||||
|
'Waiting to upload': '等待上传',
|
||||||
|
'Skipped': '已跳过',
|
||||||
|
'Upload succeeded': '上传成功',
|
||||||
|
'Upload failed': '上传失败',
|
||||||
|
'No items selected for upload': '未选择任何可上传项',
|
||||||
|
'No uploadable files or directories found': '未找到可上传的文件或目录',
|
||||||
|
'Missing file content': '缺少文件内容',
|
||||||
|
'Directory conflicts with existing file': '目标存在同名文件,无法创建目录',
|
||||||
|
|
||||||
// Side nav modals
|
// Side nav modals
|
||||||
'Join Community': '加入社区',
|
'Join Community': '加入社区',
|
||||||
'Scan to join WeChat group': '微信扫码加入交流群',
|
'Scan to join WeChat group': '微信扫码加入交流群',
|
||||||
|
|||||||
@@ -522,7 +522,8 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
|||||||
processorHook.setSelectedProcessor(type);
|
processorHook.setSelectedProcessor(type);
|
||||||
processorHook.openProcessorModal(entry);
|
processorHook.openProcessorModal(entry);
|
||||||
}}
|
}}
|
||||||
onUpload={noop}
|
onUploadFile={noop}
|
||||||
|
onUploadDirectory={noop}
|
||||||
onCreateDir={noop}
|
onCreateDir={noop}
|
||||||
onShare={doShare}
|
onShare={doShare}
|
||||||
onGetDirectLink={doGetDirectLink}
|
onGetDirectLink={doGetDirectLink}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||||
const uploader = useUploader(path, refresh);
|
const uploader = useUploader(path, refresh);
|
||||||
const { handleFileDrop } = uploader;
|
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
|
||||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||||
const { thumbs } = useThumbnails(entries, path);
|
const { thumbs } = useThumbnails(entries, path);
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
||||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
||||||
const [detailData, setDetailData] = useState<any>(null);
|
const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||||
@@ -79,9 +79,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
try {
|
try {
|
||||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||||
const stat = await vfsApi.stat(fullPath);
|
const stat = await vfsApi.stat(fullPath);
|
||||||
setDetailData(stat);
|
setDetailData(stat as Record<string, unknown>);
|
||||||
} catch (e: any) {
|
} catch (error) {
|
||||||
setDetailData({ error: e.message });
|
const messageText = error instanceof Error ? error.message : String(error);
|
||||||
|
setDetailData({ error: messageText });
|
||||||
} finally {
|
} finally {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
dragCounter.current = 0;
|
dragCounter.current = 0;
|
||||||
handleFileDrop(e.dataTransfer.files);
|
void handleFileDrop(e.dataTransfer);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -161,12 +162,26 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
onNavigate={navigateTo}
|
onNavigate={navigateTo}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
onCreateDir={() => setCreatingDir(true)}
|
onCreateDir={() => setCreatingDir(true)}
|
||||||
onUpload={uploader.openModal}
|
onUploadFile={openFilePicker}
|
||||||
|
onUploadDirectory={openDirectoryPicker}
|
||||||
onSetViewMode={setViewMode}
|
onSetViewMode={setViewMode}
|
||||||
onSortChange={handleSortChange}
|
onSortChange={handleSortChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input ref={uploader.fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={uploader.handleFileChange} />
|
<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', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||||
{loading && (entries.length === 0 || path !== routePath) ? (
|
{loading && (entries.length === 0 || path !== routePath) ? (
|
||||||
@@ -284,7 +299,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
processorHook.setSelectedProcessor(type);
|
processorHook.setSelectedProcessor(type);
|
||||||
processorHook.openProcessorModal(entry);
|
processorHook.openProcessorModal(entry);
|
||||||
}}
|
}}
|
||||||
onUpload={uploader.openModal}
|
onUploadFile={openFilePicker}
|
||||||
|
onUploadDirectory={openDirectoryPicker}
|
||||||
onCreateDir={() => setCreatingDir(true)}
|
onCreateDir={() => setCreatingDir(true)}
|
||||||
onShare={doShare}
|
onShare={doShare}
|
||||||
onGetDirectLink={doGetDirectLink}
|
onGetDirectLink={doGetDirectLink}
|
||||||
@@ -295,8 +311,14 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
|||||||
<UploadModal
|
<UploadModal
|
||||||
visible={uploader.isModalVisible}
|
visible={uploader.isModalVisible}
|
||||||
files={uploader.files}
|
files={uploader.files}
|
||||||
|
isUploading={uploader.isUploading}
|
||||||
|
totalProgress={uploader.totalProgress}
|
||||||
|
totalFileBytes={uploader.totalFileBytes}
|
||||||
|
uploadedFileBytes={uploader.uploadedFileBytes}
|
||||||
|
conflict={uploader.conflict}
|
||||||
onClose={uploader.closeModal}
|
onClose={uploader.closeModal}
|
||||||
onStartUpload={uploader.startUpload}
|
onStartUpload={uploader.startUpload}
|
||||||
|
onResolveConflict={uploader.confirmConflict}
|
||||||
/>
|
/>
|
||||||
<DropzoneOverlay visible={isDragging} />
|
<DropzoneOverlay visible={isDragging} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Menu, theme } from 'antd';
|
import { Menu, theme } from 'antd';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
import type { VfsEntry } from '../../../api/client';
|
import type { VfsEntry } from '../../../api/client';
|
||||||
|
import type { ProcessorTypeMeta } from '../../../api/processors';
|
||||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||||
import { useI18n } from '../../../i18n';
|
import { useI18n } from '../../../i18n';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +17,7 @@ interface ContextMenuProps {
|
|||||||
entry?: VfsEntry;
|
entry?: VfsEntry;
|
||||||
entries: VfsEntry[];
|
entries: VfsEntry[];
|
||||||
selectedEntries: string[];
|
selectedEntries: string[];
|
||||||
processorTypes: any[];
|
processorTypes: ProcessorTypeMeta[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOpen: (entry: VfsEntry) => void;
|
onOpen: (entry: VfsEntry) => void;
|
||||||
onOpenWith: (entry: VfsEntry, appKey: string) => void;
|
onOpenWith: (entry: VfsEntry, appKey: string) => void;
|
||||||
@@ -24,7 +26,8 @@ interface ContextMenuProps {
|
|||||||
onDelete: (entries: VfsEntry[]) => void;
|
onDelete: (entries: VfsEntry[]) => void;
|
||||||
onDetail: (entry: VfsEntry) => void;
|
onDetail: (entry: VfsEntry) => void;
|
||||||
onProcess: (entry: VfsEntry, processorType: string) => void;
|
onProcess: (entry: VfsEntry, processorType: string) => void;
|
||||||
onUpload: () => void;
|
onUploadFile: () => void;
|
||||||
|
onUploadDirectory: () => void;
|
||||||
onCreateDir: () => void;
|
onCreateDir: () => void;
|
||||||
onShare: (entries: VfsEntry[]) => void;
|
onShare: (entries: VfsEntry[]) => void;
|
||||||
onGetDirectLink: (entry: VfsEntry) => void;
|
onGetDirectLink: (entry: VfsEntry) => void;
|
||||||
@@ -32,6 +35,18 @@ interface ContextMenuProps {
|
|||||||
onCopy: (entries: VfsEntry[]) => void;
|
onCopy: (entries: VfsEntry[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MenuItem = Required<MenuProps>['items'][number];
|
||||||
|
|
||||||
|
interface ActionMenuItem {
|
||||||
|
key: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
children?: ActionMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -43,10 +58,18 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
setPosition({ left: x, top: y });
|
setPosition({ left: x, top: y });
|
||||||
}, [x, y]);
|
}, [x, y]);
|
||||||
|
|
||||||
const getContextMenuItems = () => {
|
const getContextMenuItems = (): ActionMenuItem[] => {
|
||||||
if (!entry) { // Blank context menu
|
if (!entry) { // Blank context menu
|
||||||
return [
|
return [
|
||||||
{ key: 'upload', label: t('Upload File'), icon: <UploadOutlined />, onClick: actions.onUpload },
|
{
|
||||||
|
key: 'upload',
|
||||||
|
label: t('Upload'),
|
||||||
|
icon: <UploadOutlined />,
|
||||||
|
children: [
|
||||||
|
{ key: 'upload-file', label: t('Upload Files'), onClick: actions.onUploadFile },
|
||||||
|
{ key: 'upload-folder', label: t('Upload Folder'), onClick: actions.onUploadDirectory },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -57,7 +80,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
const targetNames = selectedEntries.includes(entry.name) ? selectedEntries : [entry.name];
|
const targetNames = selectedEntries.includes(entry.name) ? selectedEntries : [entry.name];
|
||||||
const targetEntries = entries.filter(e => targetNames.includes(e.name));
|
const targetEntries = entries.filter(e => targetNames.includes(e.name));
|
||||||
|
|
||||||
let processorSubMenu: any[] = [];
|
let processorSubMenu: ActionMenuItem[] = [];
|
||||||
if (!entry.is_dir && processorTypes.length > 0) {
|
if (!entry.is_dir && processorTypes.length > 0) {
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||||
processorSubMenu = processorTypes
|
processorSubMenu = processorTypes
|
||||||
@@ -73,7 +96,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const menuItems: (ActionMenuItem | null)[] = [
|
||||||
(entry.is_dir || apps.length > 0) ? {
|
(entry.is_dir || apps.length > 0) ? {
|
||||||
key: 'open',
|
key: 'open',
|
||||||
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
|
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
|
||||||
@@ -151,18 +174,32 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
icon: <InfoCircleOutlined />,
|
icon: <InfoCircleOutlined />,
|
||||||
onClick: () => actions.onDetail(entry),
|
onClick: () => actions.onDetail(entry),
|
||||||
},
|
},
|
||||||
].filter(Boolean);
|
];
|
||||||
|
|
||||||
|
return menuItems.filter((item): item is ActionMenuItem => item !== null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = getContextMenuItems()
|
const actionItems = getContextMenuItems();
|
||||||
.filter(item => item !== null) // Ensure no null items
|
|
||||||
.map(item => ({
|
const handlerMap = new Map<string, () => void>();
|
||||||
...item,
|
|
||||||
onClick: () => {
|
const mapItems = (source: ActionMenuItem[]): MenuItem[] =>
|
||||||
if (item.onClick) item.onClick();
|
source.map<MenuItem>((item) => {
|
||||||
onClose();
|
if (item.onClick) handlerMap.set(item.key, item.onClick);
|
||||||
}
|
const mappedChildren = item.children && item.children.length > 0 ? mapItems(item.children) : undefined;
|
||||||
}));
|
|
||||||
|
const transformed = {
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
icon: item.icon,
|
||||||
|
disabled: item.disabled,
|
||||||
|
danger: item.danger,
|
||||||
|
...(mappedChildren ? { children: mappedChildren } : {}),
|
||||||
|
} as MenuItem;
|
||||||
|
return transformed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = mapItems(actionItems);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -203,8 +240,13 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
|||||||
onClick={onClose} // Close on any click inside the menu area
|
onClick={onClose} // Close on any click inside the menu area
|
||||||
>
|
>
|
||||||
<Menu
|
<Menu
|
||||||
items={items as any[]}
|
items={items}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
|
onClick={({ key }) => {
|
||||||
|
const handler = handlerMap.get(String(key));
|
||||||
|
if (handler) handler();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
|
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
|
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme, Dropdown } from 'antd';
|
||||||
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||||
import { Select } from 'antd';
|
import { Select } from 'antd';
|
||||||
import { useI18n } from '../../../i18n';
|
import { useI18n } from '../../../i18n';
|
||||||
@@ -16,7 +16,8 @@ interface HeaderProps {
|
|||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateDir: () => void;
|
onCreateDir: () => void;
|
||||||
onUpload: () => void;
|
onUploadFile: () => void;
|
||||||
|
onUploadDirectory: () => void;
|
||||||
onSetViewMode: (mode: ViewMode) => void;
|
onSetViewMode: (mode: ViewMode) => void;
|
||||||
onSortChange: (sortBy: string, sortOrder: string) => void;
|
onSortChange: (sortBy: string, sortOrder: string) => void;
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,8 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCreateDir,
|
onCreateDir,
|
||||||
onUpload,
|
onUploadFile,
|
||||||
|
onUploadDirectory,
|
||||||
onSetViewMode,
|
onSetViewMode,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -108,7 +110,26 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<Space size={8} wrap>
|
<Space size={8} wrap>
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
|
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
|
||||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
|
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
|
||||||
<Button size="small" icon={<UploadOutlined />} onClick={onUpload}>{t('Upload')}</Button>
|
<Dropdown.Button
|
||||||
|
size="small"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
onClick={onUploadFile}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'file', label: t('Upload Files') },
|
||||||
|
{ key: 'folder', label: t('Upload Folder') },
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'folder') {
|
||||||
|
onUploadDirectory();
|
||||||
|
} else {
|
||||||
|
onUploadFile();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Upload')}
|
||||||
|
</Dropdown.Button>
|
||||||
<Select
|
<Select
|
||||||
size="small"
|
size="small"
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
@@ -128,7 +149,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<Segmented
|
<Segmented
|
||||||
size="small"
|
size="small"
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={v => onSetViewMode(v as any)}
|
onChange={value => onSetViewMode(value as ViewMode)}
|
||||||
options={[
|
options={[
|
||||||
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||||
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||||
|
|||||||
@@ -1,24 +1,57 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
|
import { Modal, Button, List, Progress, Typography, message, Flex, Tag, Space } from 'antd';
|
||||||
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||||
import type { UploadFile } from '../../hooks/useUploader';
|
import type { ConflictDecision, UploadConflict, UploadFile } from '../../hooks/useUploader';
|
||||||
import { useI18n } from '../../../../i18n';
|
import { useI18n } from '../../../../i18n';
|
||||||
|
|
||||||
interface UploadModalProps {
|
interface UploadModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
files: UploadFile[];
|
files: UploadFile[];
|
||||||
|
isUploading: boolean;
|
||||||
|
totalProgress: number;
|
||||||
|
totalFileBytes: number;
|
||||||
|
uploadedFileBytes: number;
|
||||||
|
conflict: UploadConflict | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onStartUpload: () => void;
|
onStartUpload: () => void;
|
||||||
|
onResolveConflict: (decision: ConflictDecision) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onStartUpload }) => {
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||||
|
const value = bytes / (1024 ** index);
|
||||||
|
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UploadModal: React.FC<UploadModalProps> = ({
|
||||||
|
visible,
|
||||||
|
files,
|
||||||
|
isUploading,
|
||||||
|
totalProgress,
|
||||||
|
totalFileBytes,
|
||||||
|
uploadedFileBytes,
|
||||||
|
conflict,
|
||||||
|
onClose,
|
||||||
|
onStartUpload,
|
||||||
|
onResolveConflict,
|
||||||
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const allSuccess = files.every(f => f.status === 'success');
|
const summary = useMemo(() => {
|
||||||
|
const total = files.length;
|
||||||
|
const completed = files.filter(f => ['success', 'skipped'].includes(f.status)).length;
|
||||||
|
const failures = files.filter(f => f.status === 'error').length;
|
||||||
|
const pending = files.filter(f => ['pending', 'waiting', 'uploading'].includes(f.status)).length;
|
||||||
|
return { total, completed, failures, pending };
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const allFinished = files.length > 0 && files.every(f => ['success', 'error', 'skipped'].includes(f.status));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && files.length > 0 && files.every(f => f.status === 'pending')) {
|
if (visible && files.length > 0 && files.some(f => f.status === 'pending')) {
|
||||||
onStartUpload();
|
onStartUpload();
|
||||||
}
|
}
|
||||||
}, [visible, files, onStartUpload]);
|
}, [visible, files, onStartUpload]);
|
||||||
|
|
||||||
@@ -28,6 +61,29 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderStatus = (file: UploadFile) => {
|
const renderStatus = (file: UploadFile) => {
|
||||||
|
if (file.type === 'directory') {
|
||||||
|
if (file.status === 'uploading') {
|
||||||
|
return <Typography.Text type="secondary">{t('Creating directory...')}</Typography.Text>;
|
||||||
|
}
|
||||||
|
if (file.status === 'success') {
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
|
||||||
|
<Typography.Text type="secondary">{t('Directory ready')}</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.status === 'error') {
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
|
||||||
|
<Typography.Text type="danger" title={file.error}>{t('Create directory failed')}</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Typography.Text type="secondary">{t('Waiting to create')}</Typography.Text>;
|
||||||
|
}
|
||||||
|
|
||||||
switch (file.status) {
|
switch (file.status) {
|
||||||
case 'uploading':
|
case 'uploading':
|
||||||
return <Progress percent={Math.round(file.progress)} size="small" />;
|
return <Progress percent={Math.round(file.progress)} size="small" />;
|
||||||
@@ -39,6 +95,10 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
|||||||
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
case 'waiting':
|
||||||
|
return <Typography.Text type="warning">{t('Waiting for overwrite decision')}</Typography.Text>;
|
||||||
|
case 'skipped':
|
||||||
|
return <Typography.Text type="secondary">{t('Skipped')}</Typography.Text>;
|
||||||
case 'error':
|
case 'error':
|
||||||
return (
|
return (
|
||||||
<Flex align="center" gap={8}>
|
<Flex align="center" gap={8}>
|
||||||
@@ -56,13 +116,72 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
|||||||
open={visible}
|
open={visible}
|
||||||
title={t('Upload File')}
|
title={t('Upload File')}
|
||||||
width={600}
|
width={600}
|
||||||
|
closable={!isUploading}
|
||||||
|
maskClosable={!isUploading}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
|
<Button key="close" onClick={onClose} disabled={!allFinished || isUploading}>
|
||||||
{allSuccess ? t('Close') : t('Done')}
|
{allFinished ? t('Close') : t('Done')}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||||
|
<div>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Typography.Text strong>
|
||||||
|
{t('Total progress')}:
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('Upload bytes summary', {
|
||||||
|
uploaded: formatBytes(uploadedFileBytes),
|
||||||
|
total: formatBytes(totalFileBytes),
|
||||||
|
})}
|
||||||
|
</Typography.Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress percent={Math.round(totalProgress)} showInfo />
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('Upload task summary', {
|
||||||
|
completed: summary.completed,
|
||||||
|
total: summary.total,
|
||||||
|
pending: summary.pending,
|
||||||
|
failures: summary.failures,
|
||||||
|
})}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conflict && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--ant-color-warning-border, #faad14)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: 'var(--ant-color-warning-bg, rgba(250,173,20,0.1))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{t('Overwrite confirmation required')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
{t('Target already exists: {path}', { path: conflict.relativePath })}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Flex gap={8} wrap="wrap">
|
||||||
|
<Button size="small" type="primary" onClick={() => onResolveConflict('overwrite')}>
|
||||||
|
{t('Overwrite')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => onResolveConflict('skip')}>
|
||||||
|
{t('Skip')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" type="primary" onClick={() => onResolveConflict('overwriteAll')}>
|
||||||
|
{t('Overwrite All')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => onResolveConflict('skipAll')}>
|
||||||
|
{t('Skip All')}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
<List
|
<List
|
||||||
dataSource={files}
|
dataSource={files}
|
||||||
itemLayout="horizontal"
|
itemLayout="horizontal"
|
||||||
@@ -77,9 +196,16 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
|||||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||||
>
|
>
|
||||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||||
<Typography.Text ellipsis={{ tooltip: file.file.name }} style={{ maxWidth: '60%' }}>
|
<Flex align="center" gap={8} style={{ maxWidth: '60%', overflow: 'hidden' }}>
|
||||||
{file.file.name}
|
<Typography.Text ellipsis={{ tooltip: file.relativePath }} style={{ maxWidth: '100%' }}>
|
||||||
</Typography.Text>
|
{file.relativePath}
|
||||||
|
</Typography.Text>
|
||||||
|
{file.type === 'directory' ? (
|
||||||
|
<Tag color="blue">{t('Directory')}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="geekblue">{formatBytes(file.size)}</Tag>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
<div style={{ minWidth: 180, textAlign: 'right', flexShrink: 0 }}>
|
<div style={{ minWidth: 180, textAlign: 'right', flexShrink: 0 }}>
|
||||||
{renderStatus(file)}
|
{renderStatus(file)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,103 +1,592 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import type { ChangeEvent, RefObject } from 'react';
|
||||||
|
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
import { vfsApi } from '../../../api/client';
|
import { vfsApi } from '../../../api/client';
|
||||||
import { message }
|
import { useI18n } from '../../../i18n';
|
||||||
from 'antd';
|
|
||||||
|
type UploadStatus = 'pending' | 'waiting' | 'uploading' | 'success' | 'error' | 'skipped';
|
||||||
|
|
||||||
export interface UploadFile {
|
export interface UploadFile {
|
||||||
id: string;
|
id: string;
|
||||||
file: File;
|
name: string;
|
||||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
relativePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
size: number;
|
||||||
|
loadedBytes: number;
|
||||||
|
status: UploadStatus;
|
||||||
progress: number;
|
progress: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
permanentLink?: string;
|
permanentLink?: string;
|
||||||
|
file?: File;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConflictDecision = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll';
|
||||||
|
|
||||||
|
export interface UploadConflict {
|
||||||
|
taskId: string;
|
||||||
|
relativePath: string;
|
||||||
|
targetPath: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawUploadFile {
|
||||||
|
kind: 'file';
|
||||||
|
relativePath: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawUploadDirectory {
|
||||||
|
kind: 'directory';
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawUploadItem = RawUploadFile | RawUploadDirectory;
|
||||||
|
|
||||||
|
const generateId = (() => {
|
||||||
|
const cryptoApi = typeof crypto !== 'undefined' ? crypto : undefined;
|
||||||
|
return () => {
|
||||||
|
if (cryptoApi?.randomUUID) return cryptoApi.randomUUID();
|
||||||
|
return `upload-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const normalizeRelativePath = (path: string) => path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const joinWithBasePath = (base: string, relative: string) => {
|
||||||
|
const cleanedBase = base === '/' ? '' : base.replace(/\/+$/, '');
|
||||||
|
const cleanedRelative = normalizeRelativePath(relative);
|
||||||
|
const parts = [cleanedBase, cleanedRelative].filter(Boolean);
|
||||||
|
const joined = parts.join('/');
|
||||||
|
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectParentDirectories = (relativePath: string) => {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized) return [];
|
||||||
|
const segments = normalized.split('/').slice(0, -1);
|
||||||
|
const dirs: string[] = [];
|
||||||
|
for (let i = 1; i <= segments.length; i += 1) {
|
||||||
|
const dir = segments.slice(0, i).join('/');
|
||||||
|
if (dir) dirs.push(dir);
|
||||||
|
}
|
||||||
|
return dirs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectAllDirectories = (items: RawUploadItem[]) => {
|
||||||
|
const directories = new Set<string>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.kind === 'directory') {
|
||||||
|
const normalized = normalizeRelativePath(item.relativePath);
|
||||||
|
if (normalized) directories.add(normalized);
|
||||||
|
} else {
|
||||||
|
collectParentDirectories(item.relativePath).forEach((dir) => directories.add(dir));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(directories).sort((a, b) => a.localeCompare(b));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WebkitFileSystemFileEntry {
|
||||||
|
isFile: true;
|
||||||
|
isDirectory: false;
|
||||||
|
name: string;
|
||||||
|
fullPath: string;
|
||||||
|
file: (
|
||||||
|
successCallback: (file: File) => void,
|
||||||
|
errorCallback?: (err: DOMException) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebkitFileSystemDirectoryReader {
|
||||||
|
readEntries: (
|
||||||
|
successCallback: (entries: WebkitFileSystemEntry[]) => void,
|
||||||
|
errorCallback?: (err: DOMException) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebkitFileSystemDirectoryEntry {
|
||||||
|
isFile: false;
|
||||||
|
isDirectory: true;
|
||||||
|
name: string;
|
||||||
|
fullPath: string;
|
||||||
|
createReader: () => WebkitFileSystemDirectoryReader;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebkitFileSystemEntry = WebkitFileSystemFileEntry | WebkitFileSystemDirectoryEntry;
|
||||||
|
|
||||||
|
const safeStat = async (fullPath: string): Promise<{ is_dir?: boolean } | null> => {
|
||||||
|
try {
|
||||||
|
return await vfsApi.stat(fullPath) as { is_dir?: boolean };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAllDirectoryEntries = (directoryEntry: WebkitFileSystemDirectoryEntry): Promise<WebkitFileSystemEntry[]> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const reader = directoryEntry.createReader();
|
||||||
|
const entries: WebkitFileSystemEntry[] = [];
|
||||||
|
const readBatch = () => {
|
||||||
|
reader.readEntries(
|
||||||
|
(batch: WebkitFileSystemEntry[]) => {
|
||||||
|
if (batch.length === 0) {
|
||||||
|
resolve(entries);
|
||||||
|
} else {
|
||||||
|
entries.push(...batch);
|
||||||
|
readBatch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err: DOMException) => reject(err),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
readBatch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const traverseEntry = async (
|
||||||
|
entry: WebkitFileSystemEntry,
|
||||||
|
parentPath: string,
|
||||||
|
bucket: RawUploadItem[],
|
||||||
|
) => {
|
||||||
|
if (!entry) return;
|
||||||
|
const currentPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file: File = await new Promise((resolve, reject) => {
|
||||||
|
entry.file(
|
||||||
|
(f: File) => resolve(f),
|
||||||
|
(err: DOMException) => reject(err),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
bucket.push({
|
||||||
|
kind: 'file',
|
||||||
|
relativePath: currentPath,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
bucket.push({
|
||||||
|
kind: 'directory',
|
||||||
|
relativePath: currentPath,
|
||||||
|
});
|
||||||
|
const entries = await readAllDirectoryEntries(entry);
|
||||||
|
for (const child of entries) {
|
||||||
|
await traverseEntry(child, currentPath, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectFromFileList = async (list: FileList): Promise<RawUploadItem[]> => {
|
||||||
|
const items: RawUploadItem[] = [];
|
||||||
|
for (const file of Array.from(list)) {
|
||||||
|
const fileWithPath = file as File & { webkitRelativePath?: string };
|
||||||
|
const relativePath = fileWithPath.webkitRelativePath || file.name;
|
||||||
|
items.push({
|
||||||
|
kind: 'file',
|
||||||
|
relativePath,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectFromDataTransfer = async (dataTransfer: DataTransfer): Promise<RawUploadItem[]> => {
|
||||||
|
const items: RawUploadItem[] = [];
|
||||||
|
if (dataTransfer.items && dataTransfer.items.length > 0) {
|
||||||
|
for (const item of Array.from(dataTransfer.items)) {
|
||||||
|
const itemWithEntry = item as DataTransferItem & {
|
||||||
|
webkitGetAsEntry?: () => FileSystemEntry | null;
|
||||||
|
};
|
||||||
|
const entry = itemWithEntry.webkitGetAsEntry ? (itemWithEntry.webkitGetAsEntry() as unknown as WebkitFileSystemEntry) : null;
|
||||||
|
if (entry) {
|
||||||
|
await traverseEntry(entry, '', items);
|
||||||
|
} else if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
items.push({
|
||||||
|
kind: 'file',
|
||||||
|
relativePath: file.name,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dataTransfer.files && dataTransfer.files.length > 0) {
|
||||||
|
return collectFromFileList(dataTransfer.files);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUploadTasks = (basePath: string, items: RawUploadItem[]): UploadFile[] => {
|
||||||
|
const idGenerator = generateId;
|
||||||
|
const directories = collectAllDirectories(items);
|
||||||
|
const directoryTasks: UploadFile[] = directories.map((relativePath) => {
|
||||||
|
const targetPath = joinWithBasePath(basePath, relativePath);
|
||||||
|
const segments = normalizeRelativePath(relativePath).split('/');
|
||||||
|
const name = segments[segments.length - 1] || targetPath;
|
||||||
|
return {
|
||||||
|
id: idGenerator(),
|
||||||
|
name,
|
||||||
|
relativePath,
|
||||||
|
targetPath,
|
||||||
|
type: 'directory',
|
||||||
|
size: 0,
|
||||||
|
loadedBytes: 0,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileTasks: UploadFile[] = items
|
||||||
|
.filter((item): item is RawUploadFile => item.kind === 'file')
|
||||||
|
.map((item) => {
|
||||||
|
const relativePath = normalizeRelativePath(item.relativePath) || item.file.name;
|
||||||
|
const targetPath = joinWithBasePath(basePath, relativePath);
|
||||||
|
return {
|
||||||
|
id: idGenerator(),
|
||||||
|
name: item.file.name,
|
||||||
|
relativePath,
|
||||||
|
targetPath,
|
||||||
|
type: 'file',
|
||||||
|
size: item.file.size,
|
||||||
|
loadedBytes: 0,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
file: item.file,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...directoryTasks, ...fileTasks];
|
||||||
|
};
|
||||||
|
|
||||||
export function useUploader(path: string, onUploadComplete: () => void) {
|
export function useUploader(path: string, onUploadComplete: () => void) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
||||||
|
const conflictResolverRef = useRef<((decision: ConflictDecision) => void) | null>(null);
|
||||||
|
const overwriteAllRef = useRef(false);
|
||||||
|
const skipAllRef = useRef(false);
|
||||||
|
const createdDirsRef = useRef<Set<string>>(new Set());
|
||||||
|
const filesRef = useRef<UploadFile[]>(files);
|
||||||
|
const isUploadingRef = useRef(false);
|
||||||
|
|
||||||
const openModal = useCallback(() => {
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = directoryInputRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
node.setAttribute('webkitdirectory', '');
|
||||||
|
node.setAttribute('directory', '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const mutateFiles = useCallback((updater: (prev: UploadFile[]) => UploadFile[]) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
const next = updater(prev);
|
||||||
|
filesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceFiles = useCallback((next: UploadFile[]) => {
|
||||||
|
filesRef.current = next;
|
||||||
|
setFiles(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateFile = useCallback((id: string, patch: Partial<UploadFile>) => {
|
||||||
|
mutateFiles((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
||||||
|
}, [mutateFiles]);
|
||||||
|
|
||||||
|
const resetOverwriteDecisions = useCallback(() => {
|
||||||
|
overwriteAllRef.current = false;
|
||||||
|
skipAllRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openFilePicker = useCallback(() => {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click();
|
fileInputRef.current.click();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const openDirectoryPicker = useCallback(() => {
|
||||||
setIsModalVisible(false);
|
if (directoryInputRef.current) {
|
||||||
setFiles([]);
|
directoryInputRef.current.click();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const closeModal = useCallback(() => {
|
||||||
|
if (isUploadingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsModalVisible(false);
|
||||||
|
replaceFiles([]);
|
||||||
|
resetOverwriteDecisions();
|
||||||
|
setConflict(null);
|
||||||
|
conflictResolverRef.current = null;
|
||||||
|
createdDirsRef.current = new Set();
|
||||||
|
}, [replaceFiles, resetOverwriteDecisions]);
|
||||||
|
|
||||||
|
const prepareQueue = useCallback((items: RawUploadItem[]) => {
|
||||||
|
if (!items.length) {
|
||||||
|
message.info(t('No items selected for upload'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tasks = createUploadTasks(path, items);
|
||||||
|
if (!tasks.length) {
|
||||||
|
message.info(t('No uploadable files or directories found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceFiles(tasks);
|
||||||
|
resetOverwriteDecisions();
|
||||||
|
createdDirsRef.current = new Set();
|
||||||
|
setIsModalVisible(true);
|
||||||
|
}, [path, replaceFiles, resetOverwriteDecisions, t]);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>, ref: RefObject<HTMLInputElement | null>) => {
|
||||||
const selectedFiles = event.target.files;
|
const selectedFiles = event.target.files;
|
||||||
if (selectedFiles && selectedFiles.length > 0) {
|
if (!selectedFiles || selectedFiles.length === 0) {
|
||||||
const newFiles: UploadFile[] = Array.from(selectedFiles).map(file => ({
|
return;
|
||||||
id: `${file.name}-${Date.now()}`,
|
}
|
||||||
file,
|
const items = await collectFromFileList(selectedFiles);
|
||||||
status: 'pending',
|
prepareQueue(items);
|
||||||
progress: 0,
|
if (ref.current) {
|
||||||
}));
|
ref.current.value = '';
|
||||||
setFiles(newFiles);
|
}
|
||||||
setIsModalVisible(true);
|
}, [prepareQueue]);
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
const handleFileInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
await handleInputChange(event, fileInputRef);
|
||||||
|
}, [handleInputChange]);
|
||||||
|
|
||||||
|
const handleDirectoryInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
await handleInputChange(event, directoryInputRef);
|
||||||
|
}, [handleInputChange]);
|
||||||
|
|
||||||
|
const handleFileDrop = useCallback(async (data: DataTransfer) => {
|
||||||
|
const items = await collectFromDataTransfer(data);
|
||||||
|
prepareQueue(items);
|
||||||
|
}, [prepareQueue]);
|
||||||
|
|
||||||
|
const awaitConflictDecision = useCallback(async (task: UploadFile): Promise<'overwrite' | 'skip'> => {
|
||||||
|
if (overwriteAllRef.current) {
|
||||||
|
return 'overwrite';
|
||||||
|
}
|
||||||
|
if (skipAllRef.current) {
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
return new Promise<'overwrite' | 'skip'>((resolve) => {
|
||||||
|
updateFile(task.id, { status: 'waiting' });
|
||||||
|
setConflict({
|
||||||
|
taskId: task.id,
|
||||||
|
relativePath: task.relativePath,
|
||||||
|
targetPath: task.targetPath,
|
||||||
|
type: task.type,
|
||||||
|
});
|
||||||
|
conflictResolverRef.current = (decision: ConflictDecision) => {
|
||||||
|
if (decision === 'overwriteAll') {
|
||||||
|
overwriteAllRef.current = true;
|
||||||
|
resolve('overwrite');
|
||||||
|
} else if (decision === 'skipAll') {
|
||||||
|
skipAllRef.current = true;
|
||||||
|
resolve('skip');
|
||||||
|
} else if (decision === 'overwrite') {
|
||||||
|
resolve('overwrite');
|
||||||
|
} else {
|
||||||
|
resolve('skip');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [updateFile]);
|
||||||
|
|
||||||
|
const confirmConflict = useCallback((decision: ConflictDecision) => {
|
||||||
|
if (!conflictResolverRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolver = conflictResolverRef.current;
|
||||||
|
conflictResolverRef.current = null;
|
||||||
|
setConflict(null);
|
||||||
|
resolver(decision);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ensureDirectory = useCallback(async (fullPath: string) => {
|
||||||
|
const normalized = fullPath.replace(/\/+/g, '/');
|
||||||
|
if (!normalized || normalized === '/') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (createdDirsRef.current.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await vfsApi.mkdir(normalized);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const messageText = err instanceof Error ? err.message : String(err);
|
||||||
|
if (!/exist/i.test(messageText)) {
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
createdDirsRef.current.add(normalized);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleFileDrop = (droppedFiles: FileList) => {
|
const ensureDirectoryTree = useCallback(async (targetDir: string) => {
|
||||||
if (droppedFiles && droppedFiles.length > 0) {
|
if (!targetDir || targetDir === '/') return;
|
||||||
const newFiles: UploadFile[] = Array.from(droppedFiles).map(file => ({
|
const normalized = targetDir.replace(/\/+/g, '/');
|
||||||
id: `${file.name}-${Date.now()}`,
|
const segments = normalized.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||||
file,
|
let current = '';
|
||||||
status: 'pending',
|
for (const segment of segments) {
|
||||||
progress: 0,
|
current = `${current}/${segment}`;
|
||||||
}));
|
await ensureDirectory(current.startsWith('/') ? current : `/${current}`);
|
||||||
setFiles(newFiles);
|
|
||||||
setIsModalVisible(true);
|
|
||||||
}
|
}
|
||||||
};
|
}, [ensureDirectory]);
|
||||||
|
|
||||||
const startUpload = useCallback(async () => {
|
const processDirectoryTask = useCallback(async (task: UploadFile) => {
|
||||||
if (files.length === 0) {
|
updateFile(task.id, { status: 'uploading', progress: 10 });
|
||||||
|
const stat = await safeStat(task.targetPath);
|
||||||
|
if (stat && !stat.is_dir) {
|
||||||
|
const error = t('Directory conflicts with existing file');
|
||||||
|
updateFile(task.id, { status: 'error', progress: 0, error });
|
||||||
|
message.error(`${task.relativePath}: ${error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ensureDirectory(task.targetPath);
|
||||||
|
updateFile(task.id, { status: 'success', progress: 100 });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : t('Create directory failed');
|
||||||
|
updateFile(task.id, { status: 'error', progress: 0, error });
|
||||||
|
message.error(`${task.relativePath}: ${error}`);
|
||||||
|
}
|
||||||
|
}, [ensureDirectory, updateFile, t]);
|
||||||
|
|
||||||
|
const processFileTask = useCallback(async (task: UploadFile) => {
|
||||||
|
if (!task.file) {
|
||||||
|
updateFile(task.id, { status: 'error', error: t('Missing file content') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = path === '/' ? '' : path;
|
if (skipAllRef.current) {
|
||||||
|
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const uploadFile of files) {
|
let shouldOverwrite = overwriteAllRef.current;
|
||||||
if (uploadFile.status !== 'pending') continue;
|
if (!shouldOverwrite) {
|
||||||
|
const stat = await safeStat(task.targetPath);
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'uploading' } : f));
|
if (stat) {
|
||||||
|
const decision = await awaitConflictDecision(task);
|
||||||
const dest = (dir + '/' + uploadFile.file.name).replace(/\/+/g, '/');
|
if (decision === 'skip') {
|
||||||
|
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||||
try {
|
return;
|
||||||
await vfsApi.uploadStream(dest, uploadFile.file, true, (loaded, total) => {
|
}
|
||||||
const progress = total > 0 ? (loaded / total) * 100 : 0;
|
shouldOverwrite = true;
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, progress } : f));
|
|
||||||
});
|
|
||||||
|
|
||||||
const link = await vfsApi.getTempLinkToken(dest, 60 * 60 * 24 * 365 * 10);
|
|
||||||
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
|
||||||
|
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'success', progress: 100, permanentLink } : f));
|
|
||||||
} catch (e: any) {
|
|
||||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'error', error: e.message } : f));
|
|
||||||
message.error(`Upload failed: ${uploadFile.file.name} - ${e.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUploadComplete();
|
setConflict(null);
|
||||||
}, [files, path, onUploadComplete]);
|
updateFile(task.id, { status: 'uploading', progress: 0, loadedBytes: 0 });
|
||||||
|
|
||||||
|
const parentDir = task.targetPath.replace(/\/[^/]+$/, '') || '/';
|
||||||
|
try {
|
||||||
|
await ensureDirectoryTree(parentDir);
|
||||||
|
await vfsApi.uploadStream(task.targetPath, task.file, shouldOverwrite, (loaded, total) => {
|
||||||
|
mutateFiles((prev) => prev.map((f) => {
|
||||||
|
if (f.id !== task.id) return f;
|
||||||
|
const effectiveTotal = total > 0 ? total : f.size;
|
||||||
|
const size = Math.max(f.size, effectiveTotal, loaded);
|
||||||
|
const percent = size > 0 ? Math.min(100, Math.round((loaded / size) * 100)) : 0;
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
size,
|
||||||
|
loadedBytes: loaded,
|
||||||
|
progress: percent,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = await vfsApi.getTempLinkToken(task.targetPath, 60 * 60 * 24 * 365 * 10);
|
||||||
|
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||||
|
updateFile(task.id, { status: 'success', progress: 100, loadedBytes: task.size, permanentLink });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err instanceof Error ? err.message : t('Upload failed');
|
||||||
|
updateFile(task.id, { status: 'error', error, progress: 0 });
|
||||||
|
message.error(`${task.relativePath}: ${error}`);
|
||||||
|
}
|
||||||
|
}, [ensureDirectoryTree, awaitConflictDecision, mutateFiles, updateFile, t]);
|
||||||
|
|
||||||
|
const startUpload = useCallback(async () => {
|
||||||
|
if (isUploadingRef.current) return;
|
||||||
|
if (!filesRef.current.length) return;
|
||||||
|
|
||||||
|
isUploadingRef.current = true;
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
for (const task of filesRef.current) {
|
||||||
|
if (task.status !== 'pending' && task.status !== 'waiting') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (task.type === 'directory') {
|
||||||
|
await processDirectoryTask(task);
|
||||||
|
} else {
|
||||||
|
await processFileTask(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onUploadComplete();
|
||||||
|
} finally {
|
||||||
|
isUploadingRef.current = false;
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}, [onUploadComplete, processDirectoryTask, processFileTask]);
|
||||||
|
|
||||||
|
const totalFileBytes = useMemo(
|
||||||
|
() => files.reduce((acc, f) => acc + (f.type === 'file' ? f.size : 0), 0),
|
||||||
|
[files],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedFileBytes = useMemo(
|
||||||
|
() => files.reduce((acc, f) => {
|
||||||
|
if (f.type !== 'file') return acc;
|
||||||
|
const loaded = Math.min(f.loadedBytes, f.size);
|
||||||
|
if (f.status === 'success') {
|
||||||
|
return acc + (f.size || loaded);
|
||||||
|
}
|
||||||
|
if (f.status === 'uploading' || f.status === 'waiting') {
|
||||||
|
return acc + loaded;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0),
|
||||||
|
[files],
|
||||||
|
);
|
||||||
|
|
||||||
|
const directoryCounts = useMemo(() => {
|
||||||
|
const directories = files.filter((f) => f.type === 'directory');
|
||||||
|
const completed = directories.filter((f) => f.status === 'success').length;
|
||||||
|
return {
|
||||||
|
total: directories.length,
|
||||||
|
completed,
|
||||||
|
};
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const totalWeight = totalFileBytes + directoryCounts.total;
|
||||||
|
const totalProgress = totalWeight === 0
|
||||||
|
? 0
|
||||||
|
: ((uploadedFileBytes + directoryCounts.completed) / totalWeight) * 100;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
files,
|
files,
|
||||||
isModalVisible,
|
isModalVisible,
|
||||||
|
isUploading,
|
||||||
|
totalProgress: Math.min(100, Math.max(0, totalProgress)),
|
||||||
|
totalFileBytes,
|
||||||
|
uploadedFileBytes,
|
||||||
|
conflict,
|
||||||
|
confirmConflict,
|
||||||
|
resetOverwriteDecisions,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
openModal,
|
directoryInputRef,
|
||||||
|
openFilePicker,
|
||||||
|
openDirectoryPicker,
|
||||||
closeModal,
|
closeModal,
|
||||||
handleFileChange,
|
handleFileInputChange,
|
||||||
|
handleDirectoryInputChange,
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
startUpload,
|
startUpload,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user