Add custom upload confirmation and flatten folder uploads to individual files.
Deploy Ladill Transfer / deploy (push) Successful in 35s
Deploy Ladill Transfer / deploy (push) Successful in 35s
Users confirm uploads in a Ladill modal before transfer, and folder picks upload flat file names into the current location instead of preserving local directory paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+138
-3
@@ -647,6 +647,10 @@ Alpine.data('filesManager', (config = {}) => ({
|
||||
pendingUploads: null,
|
||||
pendingResolutions: {},
|
||||
duplicateQueue: [],
|
||||
uploadConfirmOpen: false,
|
||||
pendingConfirmFiles: null,
|
||||
uploadConfirmFolderName: '',
|
||||
uploadConfirmIsFolder: false,
|
||||
|
||||
triggerFileUpload() {
|
||||
this.createMenuOpen = false;
|
||||
@@ -655,9 +659,52 @@ Alpine.data('filesManager', (config = {}) => ({
|
||||
|
||||
triggerFolderUpload() {
|
||||
this.createMenuOpen = false;
|
||||
if (typeof window.showDirectoryPicker === 'function') {
|
||||
this.pickFolderWithApi();
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.folderUploadInput?.click();
|
||||
},
|
||||
|
||||
async pickFolderWithApi() {
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker();
|
||||
const files = await this.collectDirectoryFiles(handle);
|
||||
if (files.length === 0) {
|
||||
this.showToast('That folder is empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.openUploadConfirm(files, handle.name, true);
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
this.showToast('Could not read that folder.');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async collectDirectoryFiles(dirHandle, path = '') {
|
||||
const files = [];
|
||||
|
||||
for await (const entry of dirHandle.values()) {
|
||||
const entryPath = path ? `${path}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.kind === 'file') {
|
||||
const file = await entry.getFile();
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: entryPath,
|
||||
configurable: true,
|
||||
});
|
||||
files.push(file);
|
||||
} else if (entry.kind === 'directory') {
|
||||
files.push(...await this.collectDirectoryFiles(entry, entryPath));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
},
|
||||
|
||||
openFolderModal() {
|
||||
this.createMenuOpen = false;
|
||||
this.folderModalOpen = true;
|
||||
@@ -669,7 +716,92 @@ Alpine.data('filesManager', (config = {}) => ({
|
||||
const picked = Array.from(event.target.files || []);
|
||||
event.target.value = '';
|
||||
if (picked.length === 0) return;
|
||||
await this.processUploadQueue(picked);
|
||||
|
||||
const isFolder = Boolean(picked[0]?.webkitRelativePath);
|
||||
const folderName = isFolder
|
||||
? (picked[0].webkitRelativePath.split('/')[0] || 'Selected folder')
|
||||
: '';
|
||||
|
||||
this.openUploadConfirm(picked, folderName, isFolder);
|
||||
},
|
||||
|
||||
openUploadConfirm(files, folderName = '', isFolder = false) {
|
||||
this.pendingConfirmFiles = isFolder ? this.flattenFolderUploadFiles(files) : files;
|
||||
this.uploadConfirmFolderName = folderName;
|
||||
this.uploadConfirmIsFolder = isFolder;
|
||||
this.uploadConfirmOpen = true;
|
||||
},
|
||||
|
||||
fileBasename(file) {
|
||||
const relative = file.webkitRelativePath || '';
|
||||
if (relative.includes('/')) {
|
||||
return relative.split('/').pop() || file.name;
|
||||
}
|
||||
|
||||
return (file.name.split(/[/\\]/).pop() || file.name);
|
||||
},
|
||||
|
||||
flattenFolderUploadFiles(files) {
|
||||
const usedNames = new Set();
|
||||
|
||||
return files.map((file) => {
|
||||
let name = this.fileBasename(file);
|
||||
|
||||
if (usedNames.has(name)) {
|
||||
const dot = name.lastIndexOf('.');
|
||||
const stem = dot > 0 ? name.slice(0, dot) : name;
|
||||
const ext = dot > 0 ? name.slice(dot) : '';
|
||||
let counter = 1;
|
||||
|
||||
do {
|
||||
name = `${stem} (${counter})${ext}`;
|
||||
counter += 1;
|
||||
} while (usedNames.has(name));
|
||||
}
|
||||
|
||||
usedNames.add(name);
|
||||
|
||||
if (name === file.name && !file.webkitRelativePath?.includes('/')) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return new File([file], name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
cancelUploadConfirm() {
|
||||
this.uploadConfirmOpen = false;
|
||||
this.pendingConfirmFiles = null;
|
||||
this.uploadConfirmFolderName = '';
|
||||
this.uploadConfirmIsFolder = false;
|
||||
},
|
||||
|
||||
confirmUpload() {
|
||||
const files = this.pendingConfirmFiles;
|
||||
this.cancelUploadConfirm();
|
||||
if (files?.length) {
|
||||
this.processUploadQueue(files);
|
||||
}
|
||||
},
|
||||
|
||||
uploadConfirmTotalBytes() {
|
||||
return (this.pendingConfirmFiles || []).reduce((sum, file) => sum + (file.size || 0), 0);
|
||||
},
|
||||
|
||||
uploadConfirmPreviewNames() {
|
||||
return (this.pendingConfirmFiles || []).slice(0, 5).map((file) => file.name);
|
||||
},
|
||||
|
||||
uploadConfirmRemainingCount() {
|
||||
const total = (this.pendingConfirmFiles || []).length;
|
||||
return total > 5 ? total - 5 : 0;
|
||||
},
|
||||
|
||||
formatBytes(bytes) {
|
||||
return formatUploadBytes(bytes);
|
||||
},
|
||||
|
||||
async processUploadQueue(files) {
|
||||
@@ -684,7 +816,7 @@ Alpine.data('filesManager', (config = {}) => ({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filenames: files.map((f) => f.name),
|
||||
filenames: files.map((f) => this.fileBasename(f)),
|
||||
folder: this.currentFolderId || null,
|
||||
}),
|
||||
});
|
||||
@@ -733,7 +865,10 @@ Alpine.data('filesManager', (config = {}) => ({
|
||||
|
||||
async submitUpload(files, resolutions) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append('files[]', file));
|
||||
files.forEach((file) => {
|
||||
const name = this.fileBasename(file);
|
||||
formData.append('files[]', file, name);
|
||||
});
|
||||
if (this.currentFolderId) {
|
||||
formData.append('folder', this.currentFolderId);
|
||||
}
|
||||
|
||||
@@ -242,6 +242,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Upload confirmation --}}
|
||||
<div
|
||||
x-show="uploadConfirmOpen"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[60] flex items-end justify-center bg-black/40 p-4 sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="upload-confirm-title"
|
||||
@keydown.escape.window="uploadConfirmOpen && cancelUploadConfirm()"
|
||||
>
|
||||
<div @click.outside="cancelUploadConfirm()" class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 id="upload-confirm-title" class="text-lg font-semibold text-slate-900">
|
||||
Upload <span x-text="pendingConfirmFiles?.length || 0"></span>
|
||||
<span x-text="(pendingConfirmFiles?.length || 0) === 1 ? 'file' : 'files'"></span>?
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
<span x-show="uploadConfirmIsFolder">
|
||||
<span x-text="pendingConfirmFiles?.length || 0"></span>
|
||||
files from
|
||||
<span class="font-medium text-slate-800">'<span x-text="uploadConfirmFolderName"></span>'</span>
|
||||
will be uploaded individually to
|
||||
<span class="font-medium text-slate-800" x-text="currentFolderId ? 'this folder' : 'My files'"></span>.
|
||||
No new folder will be created.
|
||||
</span>
|
||||
<span x-show="!uploadConfirmIsFolder">
|
||||
Selected files will be added to
|
||||
<span class="font-medium text-slate-800" x-text="currentFolderId ? 'this folder' : 'My files'"></span>.
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Total size: <span x-text="formatBytes(uploadConfirmTotalBytes())"></span>
|
||||
</p>
|
||||
<ul class="mt-4 max-h-36 space-y-1 overflow-y-auto rounded-xl bg-slate-50 px-3 py-2 text-sm">
|
||||
<template x-for="name in uploadConfirmPreviewNames()" :key="name">
|
||||
<li class="truncate text-slate-700" x-text="name"></li>
|
||||
</template>
|
||||
<li
|
||||
x-show="uploadConfirmRemainingCount() > 0"
|
||||
class="text-slate-500"
|
||||
x-text="'and ' + uploadConfirmRemainingCount() + ' more…'"
|
||||
></li>
|
||||
</ul>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelUploadConfirm()"
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmUpload()"
|
||||
class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Duplicate file modal --}}
|
||||
<div x-show="duplicateModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl">
|
||||
|
||||
Reference in New Issue
Block a user