From 1a95915e21cbc44910dd0d5484f03b40a272a064 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 8 Jun 2026 22:31:45 +0000 Subject: [PATCH] Separate share transfers from Files storage and add folder upload to New transfer. Transfers lists only intentional shares while Files shows all storage, and New transfer accepts folder picks with flattened file uploads. Co-authored-by: Cursor --- app/Http/Controllers/SearchController.php | 1 + .../Controllers/Transfer/FilesController.php | 10 +- .../Transfer/OverviewController.php | 1 + .../Transfer/TransferController.php | 2 + app/Models/Transfer.php | 17 +++ app/Services/Transfer/FileStorageService.php | 9 +- resources/js/app.js | 105 +++++++++++------- .../views/transfer/transfers/create.blade.php | 20 +++- tests/Feature/TransferFilesTest.php | 71 +++++++++++- 9 files changed, 181 insertions(+), 55 deletions(-) diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index dc0be09..abdbde8 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -34,6 +34,7 @@ class SearchController extends Controller Transfer::query() ->where('user_id', $account->id) + ->shareTransfers() ->where('status', '!=', Transfer::STATUS_DELETED) ->where(function ($query) use ($like) { $query->where('title', 'like', $like) diff --git a/app/Http/Controllers/Transfer/FilesController.php b/app/Http/Controllers/Transfer/FilesController.php index 07e2db6..2aef591 100644 --- a/app/Http/Controllers/Transfer/FilesController.php +++ b/app/Http/Controllers/Transfer/FilesController.php @@ -48,7 +48,12 @@ class FilesController extends Controller ->where('user_id', $account->id) ->where('status', '!=', Transfer::STATUS_DELETED) ->when($folderTransfer, fn ($inner) => $inner->whereKey($folderTransfer->id), - fn ($inner) => $inner->where('is_folder', false))) + fn ($inner) => $inner->where(function ($query) { + $query->where('is_root_storage', true) + ->orWhere(function ($nested) { + $nested->where('is_folder', false)->where('is_root_storage', false); + }); + }))) ->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%')) ->with(['transfer.qrCode']) ->when($sort === 'oldest', fn ($q) => $q->oldest()) @@ -332,10 +337,11 @@ class FilesController extends Controller } $folder = $this->resolveUploadFolder($account, $data['folder'] ?? null); + $conflictLocation = $folder ?? $this->storage->rootStorage($account); $conflicts = $this->storage->findConflicts( $account, - $folder, + $conflictLocation, $data['filenames'], ); diff --git a/app/Http/Controllers/Transfer/OverviewController.php b/app/Http/Controllers/Transfer/OverviewController.php index ce71a14..05aa3bf 100644 --- a/app/Http/Controllers/Transfer/OverviewController.php +++ b/app/Http/Controllers/Transfer/OverviewController.php @@ -44,6 +44,7 @@ class OverviewController extends Controller $recentTransfers = Transfer::query() ->where('user_id', $account->id) + ->shareTransfers() ->accessible() ->with('qrCode') ->latest() diff --git a/app/Http/Controllers/Transfer/TransferController.php b/app/Http/Controllers/Transfer/TransferController.php index 6b85bc5..1c98199 100644 --- a/app/Http/Controllers/Transfer/TransferController.php +++ b/app/Http/Controllers/Transfer/TransferController.php @@ -34,6 +34,7 @@ class TransferController extends Controller $transfers = Transfer::query() ->where('user_id', $account->id) + ->shareTransfers() ->where('status', '!=', Transfer::STATUS_DELETED) ->when($search !== '', function ($query) use ($search) { $like = '%'.$search.'%'; @@ -207,5 +208,6 @@ class TransferController extends Controller private function authorizeTransfer(Transfer $transfer): void { abort_unless($transfer->user_id === ladill_account()->id, 403); + abort_if($transfer->isStorageContainer(), 404); } } diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php index dda0d95..fd15b3f 100644 --- a/app/Models/Transfer.php +++ b/app/Models/Transfer.php @@ -166,6 +166,23 @@ class Transfer extends Model return $query->where('is_folder', true); } + public function scopeStorageContainers(Builder $query): Builder + { + return $query->where(function (Builder $inner) { + $inner->where('is_folder', true)->orWhere('is_root_storage', true); + }); + } + + public function scopeShareTransfers(Builder $query): Builder + { + return $query->where('is_folder', false)->where('is_root_storage', false); + } + + public function isShareTransfer(): bool + { + return ! $this->is_folder && ! $this->is_root_storage; + } + public function isStorageContainer(): bool { return $this->is_folder || $this->is_root_storage; diff --git a/app/Services/Transfer/FileStorageService.php b/app/Services/Transfer/FileStorageService.php index 1830d66..f3ef951 100644 --- a/app/Services/Transfer/FileStorageService.php +++ b/app/Services/Transfer/FileStorageService.php @@ -149,6 +149,7 @@ class FileStorageService DB::transaction(function () use ($user, $destination, $files, $resolutions, $maxBytes, &$uploaded) { $destination = Transfer::query()->lockForUpdate()->findOrFail($destination->id); $wasEmpty = $destination->files()->count() === 0; + $location = $destination->isStorageContainer() ? $destination : null; foreach ($files as $file) { if (! $file instanceof UploadedFile) { @@ -163,15 +164,15 @@ class FileStorageService $resolution = $resolutions[$originalName] ?? null; if ($resolution === 'replace') { - $existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName); + $existing = $this->findByName($user, $location, $originalName); if ($existing) { $this->transfers->deleteFile($existing); } $storedName = $originalName; } elseif ($resolution === 'keep_both') { - $storedName = $this->uniqueFilename($user, $destination->is_folder ? $destination : null, $originalName); + $storedName = $this->uniqueFilename($user, $location, $originalName); } else { - $existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName); + $existing = $this->findByName($user, $location, $originalName); if ($existing) { throw new RuntimeException("A file named \"{$originalName}\" already exists. Choose replace or keep both."); } @@ -205,7 +206,7 @@ class FileStorageService if ($folder !== null) { $query->whereKey($folder->id); } else { - $query->where('is_folder', false); + $query->where('is_folder', false)->where('is_root_storage', false); } }) ->first(); diff --git a/resources/js/app.js b/resources/js/app.js index f5cf728..fcabad2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -312,6 +312,46 @@ function formatUploadBytes(bytes) { return `${bytes} B`; } +function uploadFileBasename(file) { + const relative = file.webkitRelativePath || ''; + if (relative.includes('/')) { + return relative.split('/').pop() || file.name; + } + + return (file.name.split(/[/\\]/).pop() || file.name); +} + +function flattenFolderUploadFiles(files) { + const usedNames = new Set(); + + return files.map((file) => { + let name = uploadFileBasename(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, + }); + }); +} + Alpine.data('transferCreateForm', (config = {}) => ({ fileItems: [], submitting: false, @@ -350,12 +390,7 @@ Alpine.data('transferCreateForm', (config = {}) => ({ return formatUploadBytes(bytes); }, - onFilesChange(event) { - if (this._ignoreInputChange) { - return; - } - - const picked = Array.from(event.target.files || []); + queueFilesForUpload(picked) { if (!picked.length) { return; } @@ -382,12 +417,32 @@ Alpine.data('transferCreateForm', (config = {}) => ({ }; this.fileItems.push(item); this._uploadQueue.push(item); + existingKeys.add(key); } - this.resetFileInput(event.target); void this.drainUploadQueue(); }, + onFilesChange(event) { + if (this._ignoreInputChange) { + return; + } + + const picked = Array.from(event.target.files || []); + this.resetFileInput(event.target); + this.queueFilesForUpload(picked); + }, + + onFolderChange(event) { + if (this._ignoreInputChange) { + return; + } + + const picked = flattenFolderUploadFiles(Array.from(event.target.files || [])); + this.resetFileInput(event.target); + this.queueFilesForUpload(picked); + }, + resetFileInput(input) { this._ignoreInputChange = true; input.value = ''; @@ -747,43 +802,11 @@ Alpine.data('filesManager', (config = {}) => ({ }, fileBasename(file) { - const relative = file.webkitRelativePath || ''; - if (relative.includes('/')) { - return relative.split('/').pop() || file.name; - } - - return (file.name.split(/[/\\]/).pop() || file.name); + return uploadFileBasename(file); }, 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, - }); - }); + return flattenFolderUploadFiles(files); }, cancelUploadConfirm() { diff --git a/resources/views/transfer/transfers/create.blade.php b/resources/views/transfer/transfers/create.blade.php index 360590f..f741905 100644 --- a/resources/views/transfer/transfers/create.blade.php +++ b/resources/views/transfer/transfers/create.blade.php @@ -35,10 +35,22 @@
- - -

Up to {{ $maxFiles }} files. Uploads start immediately — pick more files anytime before creating the transfer.

+ Files +
+ + +
+ + +

Up to {{ $maxFiles }} files. Uploads start immediately — pick files or a folder anytime before creating the transfer.