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:
@@ -126,7 +126,7 @@ class FileStorageService
|
|||||||
throw new RuntimeException($file->getClientOriginalName().' exceeds the maximum file size.');
|
throw new RuntimeException($file->getClientOriginalName().' exceeds the maximum file size.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$originalName = $file->getClientOriginalName() ?: 'file';
|
$originalName = $this->basenameFilename($file->getClientOriginalName() ?: 'file');
|
||||||
$resolution = $resolutions[$originalName] ?? null;
|
$resolution = $resolutions[$originalName] ?? null;
|
||||||
|
|
||||||
if ($resolution === 'replace') {
|
if ($resolution === 'replace') {
|
||||||
@@ -193,4 +193,12 @@ class FileStorageService
|
|||||||
|
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function basenameFilename(string $filename): string
|
||||||
|
{
|
||||||
|
$normalized = str_replace('\\', '/', trim($filename));
|
||||||
|
$base = basename($normalized);
|
||||||
|
|
||||||
|
return $base !== '' ? $base : 'file';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-3
@@ -647,6 +647,10 @@ Alpine.data('filesManager', (config = {}) => ({
|
|||||||
pendingUploads: null,
|
pendingUploads: null,
|
||||||
pendingResolutions: {},
|
pendingResolutions: {},
|
||||||
duplicateQueue: [],
|
duplicateQueue: [],
|
||||||
|
uploadConfirmOpen: false,
|
||||||
|
pendingConfirmFiles: null,
|
||||||
|
uploadConfirmFolderName: '',
|
||||||
|
uploadConfirmIsFolder: false,
|
||||||
|
|
||||||
triggerFileUpload() {
|
triggerFileUpload() {
|
||||||
this.createMenuOpen = false;
|
this.createMenuOpen = false;
|
||||||
@@ -655,9 +659,52 @@ Alpine.data('filesManager', (config = {}) => ({
|
|||||||
|
|
||||||
triggerFolderUpload() {
|
triggerFolderUpload() {
|
||||||
this.createMenuOpen = false;
|
this.createMenuOpen = false;
|
||||||
|
if (typeof window.showDirectoryPicker === 'function') {
|
||||||
|
this.pickFolderWithApi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.$refs.folderUploadInput?.click();
|
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() {
|
openFolderModal() {
|
||||||
this.createMenuOpen = false;
|
this.createMenuOpen = false;
|
||||||
this.folderModalOpen = true;
|
this.folderModalOpen = true;
|
||||||
@@ -669,7 +716,92 @@ Alpine.data('filesManager', (config = {}) => ({
|
|||||||
const picked = Array.from(event.target.files || []);
|
const picked = Array.from(event.target.files || []);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
if (picked.length === 0) return;
|
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) {
|
async processUploadQueue(files) {
|
||||||
@@ -684,7 +816,7 @@ Alpine.data('filesManager', (config = {}) => ({
|
|||||||
'X-Requested-With': 'XMLHttpRequest',
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
filenames: files.map((f) => f.name),
|
filenames: files.map((f) => this.fileBasename(f)),
|
||||||
folder: this.currentFolderId || null,
|
folder: this.currentFolderId || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -733,7 +865,10 @@ Alpine.data('filesManager', (config = {}) => ({
|
|||||||
|
|
||||||
async submitUpload(files, resolutions) {
|
async submitUpload(files, resolutions) {
|
||||||
const formData = new FormData();
|
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) {
|
if (this.currentFolderId) {
|
||||||
formData.append('folder', this.currentFolderId);
|
formData.append('folder', this.currentFolderId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 --}}
|
{{-- 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 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">
|
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl">
|
||||||
|
|||||||
@@ -166,4 +166,26 @@ class TransferFilesTest extends TestCase
|
|||||||
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief.pdf']);
|
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief.pdf']);
|
||||||
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief (1).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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user