Add custom upload confirmation and flatten folder uploads to individual files.
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:
isaacclad
2026-06-08 22:00:29 +00:00
co-authored by Cursor
parent 1f8f816f34
commit 14b00511ef
4 changed files with 230 additions and 4 deletions
+9 -1
View File
@@ -126,7 +126,7 @@ class FileStorageService
throw new RuntimeException($file->getClientOriginalName().' exceeds the maximum file size.');
}
$originalName = $file->getClientOriginalName() ?: 'file';
$originalName = $this->basenameFilename($file->getClientOriginalName() ?: 'file');
$resolution = $resolutions[$originalName] ?? null;
if ($resolution === 'replace') {
@@ -193,4 +193,12 @@ class FileStorageService
return $candidate;
}
private function basenameFilename(string $filename): string
{
$normalized = str_replace('\\', '/', trim($filename));
$base = basename($normalized);
return $base !== '' ? $base : 'file';
}
}
+138 -3
View File
@@ -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">
+22
View File
@@ -166,4 +166,26 @@ class TransferFilesTest extends TestCase
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief.pdf']);
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief (1).pdf']);
}
public function test_upload_stores_basename_when_folder_path_in_filename(): void
{
Storage::fake('qr');
$this->fakeTransferBillingApi();
$user = $this->createUser();
$this->actingAs($user)
->post(route('transfer.files.upload'), [
'files' => [
UploadedFile::fake()->createWithContent(
'red-logo/icon.svg',
'<svg></svg>',
),
],
])
->assertRedirect();
$this->assertDatabaseHas('transfer_files', ['original_name' => 'icon.svg']);
$this->assertDatabaseMissing('transfer_files', ['original_name' => 'red-logo/icon.svg']);
}
}