mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-10 17:43:35 +08:00
feat: enhance file upload functionality
This commit is contained in:
@@ -127,6 +127,8 @@ export const en = {
|
||||
|
||||
// Context menu
|
||||
'Upload File': 'Upload File',
|
||||
'Upload Files': 'Upload Files',
|
||||
'Upload Folder': 'Upload Folder',
|
||||
'Open': 'Open',
|
||||
'Open With': 'Open With',
|
||||
'Default': 'Default',
|
||||
@@ -137,6 +139,31 @@ export const en = {
|
||||
'Details': 'Details',
|
||||
'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
|
||||
'Join Community': 'Join Community',
|
||||
'Scan to join WeChat group': 'Scan to join WeChat group',
|
||||
|
||||
@@ -129,6 +129,8 @@ export const zh = {
|
||||
|
||||
// Context menu
|
||||
'Upload File': '上传文件',
|
||||
'Upload Files': '上传文件',
|
||||
'Upload Folder': '上传文件夹',
|
||||
'Open': '打开',
|
||||
'Open With': '打开方式',
|
||||
'Default': '默认',
|
||||
@@ -139,6 +141,31 @@ export const zh = {
|
||||
'Details': '详情',
|
||||
'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
|
||||
'Join Community': '加入社区',
|
||||
'Scan to join WeChat group': '微信扫码加入交流群',
|
||||
|
||||
@@ -522,7 +522,8 @@ const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
processorHook.setSelectedProcessor(type);
|
||||
processorHook.openProcessorModal(entry);
|
||||
}}
|
||||
onUpload={noop}
|
||||
onUploadFile={noop}
|
||||
onUploadDirectory={noop}
|
||||
onCreateDir={noop}
|
||||
onShare={doShare}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
|
||||
@@ -41,7 +41,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const uploader = useUploader(path, refresh);
|
||||
const { handleFileDrop } = uploader;
|
||||
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
|
||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||
const { thumbs } = useThumbnails(entries, path);
|
||||
|
||||
@@ -51,7 +51,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||
const [detailEntry, setDetailEntry] = 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 [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||
@@ -79,9 +79,10 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
try {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
const stat = await vfsApi.stat(fullPath);
|
||||
setDetailData(stat);
|
||||
} catch (e: any) {
|
||||
setDetailData({ error: e.message });
|
||||
setDetailData(stat as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
setDetailData({ error: messageText });
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
@@ -130,7 +131,7 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
handleFileDrop(e.dataTransfer.files);
|
||||
void handleFileDrop(e.dataTransfer);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -161,12 +162,26 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
onNavigate={navigateTo}
|
||||
onRefresh={refresh}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onUpload={uploader.openModal}
|
||||
onUploadFile={openFilePicker}
|
||||
onUploadDirectory={openDirectoryPicker}
|
||||
onSetViewMode={setViewMode}
|
||||
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}>
|
||||
{loading && (entries.length === 0 || path !== routePath) ? (
|
||||
@@ -284,7 +299,8 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
processorHook.setSelectedProcessor(type);
|
||||
processorHook.openProcessorModal(entry);
|
||||
}}
|
||||
onUpload={uploader.openModal}
|
||||
onUploadFile={openFilePicker}
|
||||
onUploadDirectory={openDirectoryPicker}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onShare={doShare}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
@@ -295,8 +311,14 @@ const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Menu, theme } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { ProcessorTypeMeta } from '../../../api/processors';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
@@ -15,7 +17,7 @@ interface ContextMenuProps {
|
||||
entry?: VfsEntry;
|
||||
entries: VfsEntry[];
|
||||
selectedEntries: string[];
|
||||
processorTypes: any[];
|
||||
processorTypes: ProcessorTypeMeta[];
|
||||
onClose: () => void;
|
||||
onOpen: (entry: VfsEntry) => void;
|
||||
onOpenWith: (entry: VfsEntry, appKey: string) => void;
|
||||
@@ -24,7 +26,8 @@ interface ContextMenuProps {
|
||||
onDelete: (entries: VfsEntry[]) => void;
|
||||
onDetail: (entry: VfsEntry) => void;
|
||||
onProcess: (entry: VfsEntry, processorType: string) => void;
|
||||
onUpload: () => void;
|
||||
onUploadFile: () => void;
|
||||
onUploadDirectory: () => void;
|
||||
onCreateDir: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
onGetDirectLink: (entry: VfsEntry) => void;
|
||||
@@ -32,6 +35,18 @@ interface ContextMenuProps {
|
||||
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) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
@@ -43,10 +58,18 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
setPosition({ left: x, top: y });
|
||||
}, [x, y]);
|
||||
|
||||
const getContextMenuItems = () => {
|
||||
const getContextMenuItems = (): ActionMenuItem[] => {
|
||||
if (!entry) { // Blank context menu
|
||||
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 },
|
||||
];
|
||||
}
|
||||
@@ -57,7 +80,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const targetNames = selectedEntries.includes(entry.name) ? selectedEntries : [entry.name];
|
||||
const targetEntries = entries.filter(e => targetNames.includes(e.name));
|
||||
|
||||
let processorSubMenu: any[] = [];
|
||||
let processorSubMenu: ActionMenuItem[] = [];
|
||||
if (!entry.is_dir && processorTypes.length > 0) {
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
processorSubMenu = processorTypes
|
||||
@@ -73,7 +96,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
const menuItems: (ActionMenuItem | null)[] = [
|
||||
(entry.is_dir || apps.length > 0) ? {
|
||||
key: 'open',
|
||||
label: defaultApp ? `${t('Open')} (${defaultApp.name})` : t('Open'),
|
||||
@@ -151,18 +174,32 @@ export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => actions.onDetail(entry),
|
||||
},
|
||||
].filter(Boolean);
|
||||
];
|
||||
|
||||
return menuItems.filter((item): item is ActionMenuItem => item !== null);
|
||||
};
|
||||
|
||||
const items = getContextMenuItems()
|
||||
.filter(item => item !== null) // Ensure no null items
|
||||
.map(item => ({
|
||||
...item,
|
||||
onClick: () => {
|
||||
if (item.onClick) item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}));
|
||||
const actionItems = getContextMenuItems();
|
||||
|
||||
const handlerMap = new Map<string, () => void>();
|
||||
|
||||
const mapItems = (source: ActionMenuItem[]): MenuItem[] =>
|
||||
source.map<MenuItem>((item) => {
|
||||
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(() => {
|
||||
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
|
||||
>
|
||||
<Menu
|
||||
items={items as any[]}
|
||||
items={items}
|
||||
selectable={false}
|
||||
onClick={({ key }) => {
|
||||
const handler = handlerMap.get(String(key));
|
||||
if (handler) handler();
|
||||
onClose();
|
||||
}}
|
||||
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Select } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
@@ -16,7 +16,8 @@ interface HeaderProps {
|
||||
onNavigate: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateDir: () => void;
|
||||
onUpload: () => void;
|
||||
onUploadFile: () => void;
|
||||
onUploadDirectory: () => void;
|
||||
onSetViewMode: (mode: ViewMode) => void;
|
||||
onSortChange: (sortBy: string, sortOrder: string) => void;
|
||||
}
|
||||
@@ -31,7 +32,8 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
onNavigate,
|
||||
onRefresh,
|
||||
onCreateDir,
|
||||
onUpload,
|
||||
onUploadFile,
|
||||
onUploadDirectory,
|
||||
onSetViewMode,
|
||||
onSortChange,
|
||||
}) => {
|
||||
@@ -108,7 +110,26 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<Space size={8} wrap>
|
||||
<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={<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
|
||||
size="small"
|
||||
value={sortBy}
|
||||
@@ -128,7 +149,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={v => onSetViewMode(v as any)}
|
||||
onChange={value => onSetViewMode(value as ViewMode)}
|
||||
options={[
|
||||
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Modal, Button, List, Progress, Typography, message, Flex } from 'antd';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Modal, Button, List, Progress, Typography, message, Flex, Tag, Space } from 'antd';
|
||||
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';
|
||||
|
||||
interface UploadModalProps {
|
||||
visible: boolean;
|
||||
files: UploadFile[];
|
||||
isUploading: boolean;
|
||||
totalProgress: number;
|
||||
totalFileBytes: number;
|
||||
uploadedFileBytes: number;
|
||||
conflict: UploadConflict | null;
|
||||
onClose: () => 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 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(() => {
|
||||
if (visible && files.length > 0 && files.every(f => f.status === 'pending')) {
|
||||
onStartUpload();
|
||||
if (visible && files.length > 0 && files.some(f => f.status === 'pending')) {
|
||||
onStartUpload();
|
||||
}
|
||||
}, [visible, files, onStartUpload]);
|
||||
|
||||
@@ -28,6 +61,29 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
};
|
||||
|
||||
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) {
|
||||
case 'uploading':
|
||||
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" />
|
||||
</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':
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
@@ -56,13 +116,72 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
open={visible}
|
||||
title={t('Upload File')}
|
||||
width={600}
|
||||
closable={!isUploading}
|
||||
maskClosable={!isUploading}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose} disabled={!allSuccess && files.some(f => f.status === 'uploading')}>
|
||||
{allSuccess ? t('Close') : t('Done')}
|
||||
<Button key="close" onClick={onClose} disabled={!allFinished || isUploading}>
|
||||
{allFinished ? t('Close') : t('Done')}
|
||||
</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
|
||||
dataSource={files}
|
||||
itemLayout="horizontal"
|
||||
@@ -77,9 +196,16 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||
<Typography.Text ellipsis={{ tooltip: file.file.name }} style={{ maxWidth: '60%' }}>
|
||||
{file.file.name}
|
||||
</Typography.Text>
|
||||
<Flex align="center" gap={8} style={{ maxWidth: '60%', overflow: 'hidden' }}>
|
||||
<Typography.Text ellipsis={{ tooltip: file.relativePath }} style={{ maxWidth: '100%' }}>
|
||||
{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 }}>
|
||||
{renderStatus(file)}
|
||||
</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 { message }
|
||||
from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
type UploadStatus = 'pending' | 'waiting' | 'uploading' | 'success' | 'error' | 'skipped';
|
||||
|
||||
export interface UploadFile {
|
||||
id: string;
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
name: string;
|
||||
relativePath: string;
|
||||
targetPath: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
loadedBytes: number;
|
||||
status: UploadStatus;
|
||||
progress: number;
|
||||
error?: 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) {
|
||||
const { t } = useI18n();
|
||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||
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) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setIsModalVisible(false);
|
||||
setFiles([]);
|
||||
const openDirectoryPicker = useCallback(() => {
|
||||
if (directoryInputRef.current) {
|
||||
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;
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
const newFiles: UploadFile[] = Array.from(selectedFiles).map(file => ({
|
||||
id: `${file.name}-${Date.now()}`,
|
||||
file,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
}));
|
||||
setFiles(newFiles);
|
||||
setIsModalVisible(true);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
const items = await collectFromFileList(selectedFiles);
|
||||
prepareQueue(items);
|
||||
if (ref.current) {
|
||||
ref.current.value = '';
|
||||
}
|
||||
}, [prepareQueue]);
|
||||
|
||||
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) => {
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
const newFiles: UploadFile[] = Array.from(droppedFiles).map(file => ({
|
||||
id: `${file.name}-${Date.now()}`,
|
||||
file,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
}));
|
||||
setFiles(newFiles);
|
||||
setIsModalVisible(true);
|
||||
const ensureDirectoryTree = useCallback(async (targetDir: string) => {
|
||||
if (!targetDir || targetDir === '/') return;
|
||||
const normalized = targetDir.replace(/\/+/g, '/');
|
||||
const segments = normalized.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const segment of segments) {
|
||||
current = `${current}/${segment}`;
|
||||
await ensureDirectory(current.startsWith('/') ? current : `/${current}`);
|
||||
}
|
||||
};
|
||||
}, [ensureDirectory]);
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (files.length === 0) {
|
||||
const processDirectoryTask = useCallback(async (task: UploadFile) => {
|
||||
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;
|
||||
}
|
||||
|
||||
const dir = path === '/' ? '' : path;
|
||||
if (skipAllRef.current) {
|
||||
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const uploadFile of files) {
|
||||
if (uploadFile.status !== 'pending') continue;
|
||||
|
||||
setFiles(prev => prev.map(f => f.id === uploadFile.id ? { ...f, status: 'uploading' } : f));
|
||||
|
||||
const dest = (dir + '/' + uploadFile.file.name).replace(/\/+/g, '/');
|
||||
|
||||
try {
|
||||
await vfsApi.uploadStream(dest, uploadFile.file, true, (loaded, total) => {
|
||||
const progress = total > 0 ? (loaded / total) * 100 : 0;
|
||||
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}`);
|
||||
let shouldOverwrite = overwriteAllRef.current;
|
||||
if (!shouldOverwrite) {
|
||||
const stat = await safeStat(task.targetPath);
|
||||
if (stat) {
|
||||
const decision = await awaitConflictDecision(task);
|
||||
if (decision === 'skip') {
|
||||
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||
return;
|
||||
}
|
||||
shouldOverwrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
onUploadComplete();
|
||||
}, [files, path, onUploadComplete]);
|
||||
|
||||
setConflict(null);
|
||||
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 {
|
||||
files,
|
||||
isModalVisible,
|
||||
isUploading,
|
||||
totalProgress: Math.min(100, Math.max(0, totalProgress)),
|
||||
totalFileBytes,
|
||||
uploadedFileBytes,
|
||||
conflict,
|
||||
confirmConflict,
|
||||
resetOverwriteDecisions,
|
||||
fileInputRef,
|
||||
openModal,
|
||||
directoryInputRef,
|
||||
openFilePicker,
|
||||
openDirectoryPicker,
|
||||
closeModal,
|
||||
handleFileChange,
|
||||
handleFileInputChange,
|
||||
handleDirectoryInputChange,
|
||||
handleFileDrop,
|
||||
startUpload,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user