fix: resolve IDE errors, minor UI tweaks, and implement file conflict resolution UI/API
Some checks failed
Automated Container Build / build-and-push (push) Failing after 1m13s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 1m13s
This commit is contained in:
parent
3e34a37c95
commit
10bab4ea5b
7 changed files with 625 additions and 58 deletions
|
|
@ -98,10 +98,13 @@ function FolderTreeNode({
|
|||
type ViewMode = 'grid' | 'list';
|
||||
type SidebarSection = 'files' | 'trash' | 'storage' | 'settings' | 'shared';
|
||||
|
||||
interface UploadProgress {
|
||||
interface UploadBatch {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
totalBytes: number;
|
||||
uploadedBytes: number;
|
||||
totalFiles: number;
|
||||
filesUploaded: number;
|
||||
status: 'uploading' | 'complete' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -242,9 +245,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||
const [uploads, setUploads] = useState<UploadProgress[]>([]);
|
||||
const [uploads, setUploads] = useState<UploadBatch[]>([]);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'single' | 'multiple'; file?: FileItem; count?: number } | null>(null);
|
||||
const [conflictState, setConflictState] = useState<{
|
||||
type: 'upload' | 'move' | 'copy';
|
||||
files?: {file: File, path: string}[];
|
||||
sourcePaths?: string[];
|
||||
destPath: string;
|
||||
conflicts: string[];
|
||||
} | null>(null);
|
||||
|
||||
function showToast(message: string, type: 'success' | 'error' | 'info' = 'success') {
|
||||
const id = Date.now() + Math.random();
|
||||
|
|
@ -688,7 +698,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
async function handleMoveExecute() {
|
||||
if (movingFiles.length === 0) return;
|
||||
try {
|
||||
await Promise.all(movingFiles.map(src => api.move(src, moveModalPath === '.' ? '.' : moveModalPath)));
|
||||
const destPath = moveModalPath === '.' ? '.' : moveModalPath;
|
||||
const fileNames = movingFiles.map(src => src.split('/').pop() || src);
|
||||
const res = await api.checkConflicts(destPath, fileNames);
|
||||
if (res.conflicts?.length) {
|
||||
setConflictState({ type: 'move', sourcePaths: movingFiles, destPath, conflicts: res.conflicts });
|
||||
return;
|
||||
}
|
||||
await Promise.all(movingFiles.map(src => api.move(src, destPath)));
|
||||
setShowMoveModal(false);
|
||||
setMovingFiles([]);
|
||||
loadFiles(undefined, true);
|
||||
|
|
@ -699,6 +716,59 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleDropOnFolder(file: FileItem, targetFolder: FileItem) {
|
||||
if (file.path === targetFolder.path) return;
|
||||
try {
|
||||
const res = await api.checkConflicts(targetFolder.path, [file.name]);
|
||||
if (res.conflicts?.length) {
|
||||
setConflictState({ type: 'move', sourcePaths: [file.path], destPath: targetFolder.path, conflicts: res.conflicts });
|
||||
return;
|
||||
}
|
||||
await api.move(file.path, targetFolder.path);
|
||||
loadFiles();
|
||||
showToast(`Moved ${file.name}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeConflictResolution(resolution: string) {
|
||||
if (!conflictState) return;
|
||||
const { type, destPath, conflicts } = conflictState;
|
||||
|
||||
if (type === 'upload' && conflictState.files) {
|
||||
let filesToUpload = conflictState.files;
|
||||
if (resolution === 'skip') {
|
||||
filesToUpload = filesToUpload.filter(f => {
|
||||
const name = f.path ? `${f.path}/${f.file.name}` : f.file.name;
|
||||
return !conflicts.includes(name);
|
||||
});
|
||||
}
|
||||
setConflictState(null);
|
||||
if (filesToUpload.length > 0) {
|
||||
processUploadBatch(filesToUpload, destPath, resolution === 'overwrite');
|
||||
}
|
||||
} else if (type === 'move' && conflictState.sourcePaths) {
|
||||
let pathsToMove = conflictState.sourcePaths;
|
||||
if (resolution === 'skip') {
|
||||
pathsToMove = pathsToMove.filter(p => !conflicts.includes(p.split('/').pop()!));
|
||||
}
|
||||
setConflictState(null);
|
||||
if (pathsToMove.length > 0) {
|
||||
try {
|
||||
await Promise.all(pathsToMove.map(src => api.move(src, destPath, resolution)));
|
||||
setShowMoveModal(false);
|
||||
setMovingFiles([]);
|
||||
loadFiles(undefined, true);
|
||||
setSelectedFiles(new Set());
|
||||
showToast(`Moved ${pathsToMove.length} item${pathsToMove.length > 1 ? 's' : ''}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
if (draggedFile) return;
|
||||
if (!e.dataTransfer.types.includes('Files')) return;
|
||||
|
|
@ -748,23 +818,72 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File, targetDir?: string) {
|
||||
const id = `${Date.now()}-${file.name}`;
|
||||
setUploads(prev => [...prev, { id, name: file.name, progress: 0, status: 'uploading' }]);
|
||||
try {
|
||||
const uploadPath = targetDir ? (currentPath === '.' ? targetDir : `${currentPath}/${targetDir}`) : currentPath;
|
||||
await api.upload(file, uploadPath, {
|
||||
onProgress: (percent) => {
|
||||
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: percent } : u));
|
||||
},
|
||||
});
|
||||
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: 100, status: 'complete' } : u));
|
||||
setTimeout(() => {
|
||||
setUploads(prev => prev.filter(u => u.id !== id));
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
setUploads(prev => prev.map(u => u.id === id ? { ...u, status: 'error', error: err.message || 'Upload failed' } : u));
|
||||
async function processUploadBatch(entries: {file: File, path: string}[], destPath: string, overwrite: boolean = false) {
|
||||
if (!entries.length) return;
|
||||
const batchId = 'batch-' + Date.now();
|
||||
const totalFiles = entries.length;
|
||||
const totalBytes = entries.reduce((acc, curr) => acc + curr.file.size, 0);
|
||||
const batchName = totalFiles === 1 ? entries[0].file.name : `Uploading ${totalFiles} items`;
|
||||
|
||||
setUploads(prev => [...prev, {
|
||||
id: batchId,
|
||||
name: batchName,
|
||||
totalBytes,
|
||||
uploadedBytes: 0,
|
||||
totalFiles,
|
||||
filesUploaded: 0,
|
||||
status: 'uploading'
|
||||
}]);
|
||||
|
||||
const MAX_CONCURRENT = 4;
|
||||
let currentIndex = 0;
|
||||
let completedFiles = 0;
|
||||
const fileProgressMap = new Map<string, number>();
|
||||
|
||||
const updateProgress = () => {
|
||||
let totalUploaded = 0;
|
||||
fileProgressMap.forEach(bytes => { totalUploaded += bytes; });
|
||||
setUploads(prev => prev.map(b => b.id === batchId ? {
|
||||
...b,
|
||||
uploadedBytes: totalUploaded,
|
||||
filesUploaded: completedFiles,
|
||||
status: completedFiles === totalFiles ? 'complete' : 'uploading'
|
||||
} : b));
|
||||
};
|
||||
|
||||
const worker = async () => {
|
||||
while (currentIndex < entries.length) {
|
||||
const index = currentIndex++;
|
||||
const { file, path } = entries[index];
|
||||
const uploadPath = path ? (destPath === '.' ? path : `${destPath}/${path}`) : destPath;
|
||||
|
||||
try {
|
||||
await api.upload(file, uploadPath, {
|
||||
overwrite,
|
||||
onProgress: (percent) => {
|
||||
fileProgressMap.set(file.name + path, (percent / 100) * file.size);
|
||||
updateProgress();
|
||||
}
|
||||
});
|
||||
completedFiles++;
|
||||
fileProgressMap.set(file.name + path, file.size);
|
||||
updateProgress();
|
||||
} catch (err: any) {
|
||||
setUploads(prev => prev.map(b => b.id === batchId ? { ...b, status: 'error', error: err.message || 'Upload failed' } : b));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = [];
|
||||
for (let i = 0; i < Math.min(MAX_CONCURRENT, entries.length); i++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
|
||||
setTimeout(() => {
|
||||
setUploads(prev => prev.filter(u => u.id !== batchId));
|
||||
loadFiles(undefined, true);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function handleDrop(e: React.DragEvent) {
|
||||
|
|
@ -779,7 +898,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
const processEntry = async (entry: any, path: string = '') => {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve) => entry.file(resolve));
|
||||
filesToUpload.push({ file, path });
|
||||
filesToUpload.push({ file, path: path.replace(/\/$/, '') });
|
||||
} else if (entry.isDirectory) {
|
||||
const dirReader = entry.createReader();
|
||||
let entries: any[] = [];
|
||||
|
|
@ -812,24 +931,58 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
await Promise.all(promises);
|
||||
|
||||
if (filesToUpload.length > 0) {
|
||||
await Promise.all(filesToUpload.map(({file, path}) =>
|
||||
uploadFile(file, path.replace(/\/$/, ''))
|
||||
));
|
||||
// Collect destination paths to create folders
|
||||
const uniqueDirs = new Set<string>();
|
||||
filesToUpload.forEach(({path}) => {
|
||||
if (path) uniqueDirs.add(currentPath === '.' ? path : `${currentPath}/${path}`);
|
||||
});
|
||||
|
||||
for (const dir of Array.from(uniqueDirs)) {
|
||||
try { await api.createFolder(dir); } catch {}
|
||||
}
|
||||
|
||||
const fileNames = filesToUpload.map(f => f.path ? `${f.path}/${f.file.name}` : f.file.name);
|
||||
try {
|
||||
const res = await api.checkConflicts(currentPath, fileNames);
|
||||
if (res.conflicts && res.conflicts.length > 0) {
|
||||
setConflictState({ type: 'upload', files: filesToUpload, destPath: currentPath, conflicts: res.conflicts });
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
processUploadBatch(filesToUpload, currentPath);
|
||||
}
|
||||
} else {
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
await Promise.all(droppedFiles.map(file => uploadFile(file)));
|
||||
const droppedFiles = Array.from(e.dataTransfer.files).map(f => ({ file: f, path: '' }));
|
||||
const fileNames = droppedFiles.map(f => f.file.name);
|
||||
try {
|
||||
const res = await api.checkConflicts(currentPath, fileNames);
|
||||
if (res.conflicts && res.conflicts.length > 0) {
|
||||
setConflictState({ type: 'upload', files: droppedFiles, destPath: currentPath, conflicts: res.conflicts });
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
processUploadBatch(droppedFiles, currentPath);
|
||||
}
|
||||
|
||||
loadFiles(undefined, true);
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const uploadFiles = e.target.files;
|
||||
if (!uploadFiles) return;
|
||||
await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file)));
|
||||
const filesArray = Array.from(uploadFiles).map(f => ({ file: f, path: '' }));
|
||||
|
||||
const fileNames = filesArray.map(f => f.file.name);
|
||||
try {
|
||||
const res = await api.checkConflicts(currentPath, fileNames);
|
||||
if (res.conflicts && res.conflicts.length > 0) {
|
||||
setConflictState({ type: 'upload', files: filesArray, destPath: currentPath, conflicts: res.conflicts });
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
processUploadBatch(filesArray, currentPath);
|
||||
e.target.value = '';
|
||||
loadFiles(undefined, true);
|
||||
}
|
||||
|
||||
const pathSegments = currentPath === '.' ? [] : currentPath.split('/');
|
||||
|
|
@ -1998,6 +2151,44 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{conflictState && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={() => setConflictState(null)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-md rounded-2xl shadow-xl overflow-hidden flex flex-col"
|
||||
style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', maxHeight: '90vh' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<h2 className="text-xl font-semibold" style={{ color: 'var(--color-text-primary)' }}>Name Conflict Detected</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
The following files already exist in the destination. How would you like to proceed?
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-6 text-sm flex-1" style={{ backgroundColor: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)' }}>
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
{conflictState.conflicts.map(c => <li key={c} className="break-all">{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-3 border-t" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<button onClick={() => executeConflictResolution('overwrite')} className="w-full py-2.5 rounded-xl font-medium text-white shadow-sm transition-opacity hover:opacity-90 bg-red-500">
|
||||
Replace
|
||||
</button>
|
||||
<button onClick={() => executeConflictResolution('rename')} className="w-full py-2.5 rounded-xl font-medium text-white shadow-sm transition-opacity hover:opacity-90" style={{ backgroundColor: 'var(--color-accent)' }}>
|
||||
Auto-Rename (Keep Both)
|
||||
</button>
|
||||
<button onClick={() => executeConflictResolution('skip')} className="w-full py-2.5 rounded-xl font-medium shadow-sm transition-opacity hover:opacity-90" style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-primary)' }}>
|
||||
Skip Conflicting
|
||||
</button>
|
||||
<button onClick={() => setConflictState(null)} className="w-full py-2.5 rounded-xl font-medium shadow-sm transition-opacity hover:opacity-90 border" style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-primary)' }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMoveModal && (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<motion.div
|
||||
|
|
|
|||
Reference in a new issue