feat: enhance file upload functionality

This commit is contained in:
ShiYu
2025-10-20 17:46:37 +08:00
parent 4d6e0b86ad
commit f7e6815265
8 changed files with 862 additions and 107 deletions

View File

@@ -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',

View File

@@ -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': '微信扫码加入交流群',

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' }

View File

@@ -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>

View File

@@ -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,
};