diff --git a/app/Services/Transfer/FileStorageService.php b/app/Services/Transfer/FileStorageService.php index 55bc1b8..48dccba 100644 --- a/app/Services/Transfer/FileStorageService.php +++ b/app/Services/Transfer/FileStorageService.php @@ -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'; + } } diff --git a/resources/js/app.js b/resources/js/app.js index e700fa4..7321dec 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -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); } diff --git a/resources/views/transfer/files/index.blade.php b/resources/views/transfer/files/index.blade.php index 8ee477b..bd9d6c6 100644 --- a/resources/views/transfer/files/index.blade.php +++ b/resources/views/transfer/files/index.blade.php @@ -242,6 +242,67 @@ + {{-- Upload confirmation --}} +
+ + + files from + '' + will be uploaded individually to + . + No new folder will be created. + + + Selected files will be added to + . + +
++ Total size: +
+