From f9b16f6b61f0cd6182950e896d12c1b31b3afc37 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 8 Jun 2026 21:27:39 +0000 Subject: [PATCH] Add Files page upload flow with manual folders and duplicate handling. Users can create folders, upload files or folders, and choose replace or keep both on name conflicts instead of auto-grouping by transfer. Co-authored-by: Cursor --- app/Http/Controllers/SearchController.php | 8 +- .../Controllers/Transfer/FilesController.php | 124 ++++++++++- app/Models/Transfer.php | 19 ++ app/Services/Transfer/FileStorageService.php | 196 ++++++++++++++++++ app/Services/Transfer/TransferService.php | 24 ++- ..._220000_add_storage_flags_to_transfers.php | 27 +++ public/images/ladill-icons/file.svg | 11 + public/images/ladill-icons/folder.svg | 10 + resources/js/app.js | 124 +++++++++++ .../views/transfer/files/index.blade.php | 195 ++++++++++------- routes/web.php | 3 + tests/Feature/TransferFilesTest.php | 132 ++++++++---- 12 files changed, 746 insertions(+), 127 deletions(-) create mode 100644 app/Services/Transfer/FileStorageService.php create mode 100644 database/migrations/2026_06_08_220000_add_storage_flags_to_transfers.php create mode 100644 public/images/ladill-icons/file.svg create mode 100644 public/images/ladill-icons/folder.svg diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 0499350..dc0be09 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -63,11 +63,15 @@ class SearchController extends Controller ->limit(8) ->get() ->each(function (TransferFile $file) use (&$results) { + $params = $file->transfer->is_folder + ? ['folder' => $file->transfer_id, 'q' => $file->original_name] + : ['q' => $file->original_name]; + $results[] = [ 'type' => 'file', 'title' => $file->original_name, - 'subtitle' => $file->humanSize().' · '.$file->transfer->title, - 'url' => route('transfer.files.index', ['folder' => $file->transfer_id, 'q' => $file->original_name]), + 'subtitle' => $file->humanSize().($file->transfer->is_folder ? ' · '.$file->transfer->title : ''), + 'url' => route('transfer.files.index', $params), ]; }); diff --git a/app/Http/Controllers/Transfer/FilesController.php b/app/Http/Controllers/Transfer/FilesController.php index 615f645..af7fd96 100644 --- a/app/Http/Controllers/Transfer/FilesController.php +++ b/app/Http/Controllers/Transfer/FilesController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers\Transfer; use App\Http\Controllers\Controller; use App\Models\Transfer; use App\Models\TransferFile; +use App\Models\User; +use App\Services\Transfer\FileStorageService; use App\Services\Transfer\TransferRecipientMailService; use App\Services\Transfer\TransferService; use Illuminate\Http\JsonResponse; @@ -21,6 +23,7 @@ class FilesController extends Controller { public function __construct( private TransferService $transfers, + private FileStorageService $storage, ) {} public function index(Request $request): View @@ -35,6 +38,7 @@ class FilesController extends Controller if ($folderId) { $folderTransfer = Transfer::query() ->where('user_id', $account->id) + ->where('is_folder', true) ->where('status', '!=', Transfer::STATUS_DELETED) ->findOrFail($folderId); } @@ -42,8 +46,9 @@ class FilesController extends Controller $filesQuery = TransferFile::query() ->whereHas('transfer', fn ($q) => $q ->where('user_id', $account->id) - ->where('status', '!=', Transfer::STATUS_DELETED)) - ->when($folderTransfer, fn ($q) => $q->where('transfer_id', $folderTransfer->id)) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->when($folderTransfer, fn ($inner) => $inner->whereKey($folderTransfer->id), + fn ($inner) => $inner->where('is_folder', false))) ->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%')) ->with(['transfer.qrCode']) ->when($sort === 'oldest', fn ($q) => $q->oldest()) @@ -56,8 +61,8 @@ class FilesController extends Controller $folders = Transfer::query() ->where('user_id', $account->id) + ->where('is_folder', true) ->where('status', '!=', Transfer::STATUS_DELETED) - ->whereHas('files') ->withCount('files') ->when($search !== '', fn ($q) => $q->where('title', 'like', '%'.$search.'%')) ->orderBy('title') @@ -65,9 +70,12 @@ class FilesController extends Controller $moveTargets = Transfer::query() ->where('user_id', $account->id) - ->accessible() + ->where('status', '!=', Transfer::STATUS_DELETED) + ->where(function ($query) { + $query->where('is_folder', true)->orWhere('is_root_storage', true); + }) ->orderBy('title') - ->get(['id', 'title']); + ->get(['id', 'title', 'is_root_storage']); $storageBytes = Transfer::query() ->where('user_id', $account->id) @@ -184,14 +192,17 @@ class FilesController extends Controller $destination = Transfer::query() ->where('user_id', ladill_account()->id) - ->accessible() + ->where('status', '!=', Transfer::STATUS_DELETED) + ->where(function ($query) { + $query->where('is_folder', true)->orWhere('is_root_storage', true); + }) ->findOrFail($data['destination']); $files = $this->authorizedFiles($data['files']); $this->transfers->moveFiles($destination, $files->all()); return redirect() - ->route('transfer.files.index', ['folder' => $destination->id]) + ->route('transfer.files.index', $destination->is_folder ? ['folder' => $destination->id] : []) ->with('success', $files->count().' file'.($files->count() === 1 ? '' : 's').' moved to '.$destination->title.'.'); } @@ -273,6 +284,105 @@ class FilesController extends Controller return redirect()->away($url); } + public function storeFolder(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => 'required|string|max:120', + 'folder' => 'nullable|integer|exists:transfers,id', + ]); + + $account = ladill_account(); + $parent = null; + if (! empty($data['folder'])) { + $parent = Transfer::query() + ->where('user_id', $account->id) + ->where('is_folder', true) + ->findOrFail($data['folder']); + } + + try { + $folder = $this->storage->createFolder($account, $data['name'], $parent); + } catch (RuntimeException $e) { + return back()->with('error', $e->getMessage()); + } + + return redirect() + ->route('transfer.files.index', ['folder' => $folder->id]) + ->with('success', 'Folder created.'); + } + + public function checkUpload(Request $request): JsonResponse + { + $data = $request->validate([ + 'filenames' => 'required|array|min:1', + 'filenames.*' => 'required|string|max:255', + 'folder' => 'nullable|integer|exists:transfers,id', + ]); + + $account = ladill_account(); + $folder = $this->resolveUploadFolder($account, $data['folder'] ?? null); + + $conflicts = $this->storage->findConflicts( + $account, + $folder, + $data['filenames'], + ); + + return response()->json(['conflicts' => $conflicts]); + } + + public function storeUpload(Request $request): RedirectResponse|JsonResponse + { + $data = $request->validate([ + 'files' => 'required|array|min:1', + 'files.*' => 'required|file', + 'folder' => 'nullable|integer|exists:transfers,id', + 'resolutions' => 'nullable|array', + 'resolutions.*' => 'in:replace,keep_both', + ]); + + $account = ladill_account(); + $folder = $this->resolveUploadFolder($account, $data['folder'] ?? null); + $files = array_values($request->file('files', []) ?? []); + + try { + $uploaded = $this->storage->uploadFiles( + $account, + $folder, + $files, + $data['resolutions'] ?? [], + ); + } catch (RuntimeException $e) { + if ($request->expectsJson()) { + return response()->json(['message' => $e->getMessage()], 422); + } + + return back()->with('error', $e->getMessage()); + } + + $count = count($uploaded); + $message = $count.' file'.($count === 1 ? '' : 's').' uploaded.'; + + if ($request->expectsJson()) { + return response()->json(['message' => $message, 'count' => $count]); + } + + return back()->with('success', $message); + } + + private function resolveUploadFolder(User $user, ?int $folderId): ?Transfer + { + if ($folderId === null) { + return null; + } + + return Transfer::query() + ->where('user_id', $user->id) + ->where('is_folder', true) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->findOrFail($folderId); + } + /** @param array|null $ids */ private function authorizedFiles(?array $ids): Collection { diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php index eaabaaf..dda0d95 100644 --- a/app/Models/Transfer.php +++ b/app/Models/Transfer.php @@ -34,11 +34,15 @@ class Transfer extends Model 'grace_ends_at', 'last_billed_at', 'status', + 'is_folder', + 'is_root_storage', 'downloads_total', 'storage_bytes', ]; protected $casts = [ + 'is_folder' => 'boolean', + 'is_root_storage' => 'boolean', 'retention_days' => 'integer', 'expires_at' => 'datetime', 'paid_until' => 'datetime', @@ -157,6 +161,21 @@ class Transfer extends Model }); } + public function scopeStorageFolders(Builder $query): Builder + { + return $query->where('is_folder', true); + } + + public function isStorageContainer(): bool + { + return $this->is_folder || $this->is_root_storage; + } + + public function shouldPersistWhenEmpty(): bool + { + return $this->is_folder || $this->is_root_storage; + } + public function wantsRecipientMilestone(string $milestone): bool { return in_array($milestone, (array) ($this->recipient_email_milestones ?? []), true); diff --git a/app/Services/Transfer/FileStorageService.php b/app/Services/Transfer/FileStorageService.php new file mode 100644 index 0000000..55bc1b8 --- /dev/null +++ b/app/Services/Transfer/FileStorageService.php @@ -0,0 +1,196 @@ +where('user_id', $user->id) + ->where('is_root_storage', true) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->first(); + + if ($existing) { + return $existing; + } + + $billingPeriodDays = (int) config('transfer.billing_period_days', 30); + + return Transfer::create([ + 'user_id' => $user->id, + 'title' => 'My files', + 'status' => Transfer::STATUS_ACTIVE, + 'is_root_storage' => true, + 'retention_days' => $billingPeriodDays, + ]); + } + + public function createFolder(User $user, string $name, ?Transfer $parent = null): Transfer + { + $title = trim($name); + if ($title === '') { + throw new RuntimeException('Enter a folder name.'); + } + + $billingPeriodDays = (int) config('transfer.billing_period_days', 30); + + return Transfer::create([ + 'user_id' => $user->id, + 'title' => $title, + 'status' => Transfer::STATUS_ACTIVE, + 'is_folder' => true, + 'retention_days' => $billingPeriodDays, + ]); + } + + public function uploadDestination(User $user, ?Transfer $folder): Transfer + { + if ($folder !== null) { + abort_unless($folder->user_id === $user->id && $folder->is_folder, 403); + + return $folder; + } + + return $this->rootStorage($user); + } + + /** @param list $filenames */ + public function findConflicts(User $user, ?Transfer $folder, array $filenames): array + { + $conflicts = []; + + foreach ($filenames as $filename) { + $name = trim((string) $filename); + if ($name === '') { + continue; + } + + $existing = $this->findByName($user, $folder, $name); + if ($existing) { + $conflicts[] = [ + 'filename' => $name, + 'existing_id' => $existing->id, + ]; + } + } + + return $conflicts; + } + + /** + * @param list $files + * @param array $resolutions + * @return list + */ + public function uploadFiles(User $user, ?Transfer $folder, array $files, array $resolutions = []): array + { + if ($files === []) { + throw new RuntimeException('Choose at least one file to upload.'); + } + + $destination = $this->uploadDestination($user, $folder); + $maxFiles = (int) config('transfer.max_files_per_transfer', 20); + if (count($files) > $maxFiles) { + throw new RuntimeException("You can upload up to {$maxFiles} files at a time."); + } + + $maxBytes = (int) config('transfer.max_file_bytes', 0); + $uploaded = []; + + DB::transaction(function () use ($user, $destination, $files, $resolutions, $maxBytes, &$uploaded) { + $destination = Transfer::query()->lockForUpdate()->findOrFail($destination->id); + $wasEmpty = $destination->files()->count() === 0; + + foreach ($files as $file) { + if (! $file instanceof UploadedFile) { + continue; + } + + if ($maxBytes > 0 && $file->getSize() > $maxBytes) { + throw new RuntimeException($file->getClientOriginalName().' exceeds the maximum file size.'); + } + + $originalName = $file->getClientOriginalName() ?: 'file'; + $resolution = $resolutions[$originalName] ?? null; + + if ($resolution === 'replace') { + $existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName); + if ($existing) { + $this->transfers->deleteFile($existing); + } + $storedName = $originalName; + } elseif ($resolution === 'keep_both') { + $storedName = $this->uniqueFilename($user, $destination->is_folder ? $destination : null, $originalName); + } else { + $existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName); + if ($existing) { + throw new RuntimeException("A file named \"{$originalName}\" already exists. Choose replace or keep both."); + } + $storedName = $originalName; + } + + $stored = $this->transfers->addStorageFile($user, $destination, $file, $storedName); + $uploaded[] = $stored; + } + + if ($uploaded === []) { + throw new RuntimeException('No valid files were uploaded.'); + } + + if ($wasEmpty && ! $destination->qr_code_id) { + app(TransferBillingService::class)->chargeInitialPeriod($destination->fresh(), $user); + } + }); + + return $uploaded; + } + + public function findByName(User $user, ?Transfer $folder, string $filename): ?TransferFile + { + return TransferFile::query() + ->where('original_name', $filename) + ->whereHas('transfer', function ($query) use ($user, $folder) { + $query->where('user_id', $user->id) + ->where('status', '!=', Transfer::STATUS_DELETED); + + if ($folder !== null) { + $query->whereKey($folder->id); + } else { + $query->where('is_folder', false); + } + }) + ->first(); + } + + public function uniqueFilename(User $user, ?Transfer $folder, string $filename): string + { + $base = pathinfo($filename, PATHINFO_FILENAME); + $ext = pathinfo($filename, PATHINFO_EXTENSION); + $candidate = $filename; + $counter = 1; + + while ($this->findByName($user, $folder, $candidate)) { + $suffix = ' ('.$counter.')'; + $candidate = $base.$suffix.($ext !== '' ? '.'.$ext : ''); + $counter++; + } + + return $candidate; + } +} diff --git a/app/Services/Transfer/TransferService.php b/app/Services/Transfer/TransferService.php index 47c30d3..ee8d979 100644 --- a/app/Services/Transfer/TransferService.php +++ b/app/Services/Transfer/TransferService.php @@ -122,6 +122,24 @@ class TransferService } private function storeFile(User $user, Transfer $transfer, UploadedFile $file): TransferFile + { + return $this->storeFileWithName( + $user, + $transfer, + $file, + $file->getClientOriginalName() ?: ('file.'.($file->getClientOriginalExtension() ?: 'bin')), + ); + } + + public function addStorageFile(User $user, Transfer $transfer, UploadedFile $file, string $displayName): TransferFile + { + $stored = $this->storeFileWithName($user, $transfer, $file, $displayName); + $transfer->increment('storage_bytes', $stored->size_bytes); + + return $stored; + } + + private function storeFileWithName(User $user, Transfer $transfer, UploadedFile $file, string $displayName): TransferFile { $uuid = Str::uuid()->toString(); $ext = $file->getClientOriginalExtension() ?: 'bin'; @@ -131,7 +149,7 @@ class TransferService return TransferFile::create([ 'transfer_id' => $transfer->id, - 'original_name' => $file->getClientOriginalName() ?: 'file.'.$ext, + 'original_name' => $displayName, 'disk' => 'qr', 'path' => $path, 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', @@ -208,7 +226,7 @@ class TransferService $transfer->decrement('storage_bytes', $size); - if ($transfer->files()->count() === 0) { + if ($transfer->files()->count() === 0 && ! $transfer->shouldPersistWhenEmpty()) { $this->delete($transfer->fresh(['files', 'qrCode'])); } }); @@ -248,7 +266,7 @@ class TransferService foreach ($sourceAdjustments as $sourceId => $bytes) { Transfer::query()->whereKey($sourceId)->decrement('storage_bytes', $bytes); $source = Transfer::query()->with('files')->find($sourceId); - if ($source && $source->files->isEmpty() && $source->status !== Transfer::STATUS_DELETED) { + if ($source && $source->files->isEmpty() && $source->status !== Transfer::STATUS_DELETED && ! $source->shouldPersistWhenEmpty()) { $this->delete($source->fresh(['files', 'qrCode'])); } } diff --git a/database/migrations/2026_06_08_220000_add_storage_flags_to_transfers.php b/database/migrations/2026_06_08_220000_add_storage_flags_to_transfers.php new file mode 100644 index 0000000..4e1704e --- /dev/null +++ b/database/migrations/2026_06_08_220000_add_storage_flags_to_transfers.php @@ -0,0 +1,27 @@ +boolean('is_folder')->default(false)->after('status'); + $table->boolean('is_root_storage')->default(false)->after('is_folder'); + $table->index(['user_id', 'is_folder']); + $table->index(['user_id', 'is_root_storage']); + }); + } + + public function down(): void + { + Schema::table('transfers', function (Blueprint $table) { + $table->dropIndex(['user_id', 'is_folder']); + $table->dropIndex(['user_id', 'is_root_storage']); + $table->dropColumn(['is_folder', 'is_root_storage']); + }); + } +}; diff --git a/public/images/ladill-icons/file.svg b/public/images/ladill-icons/file.svg new file mode 100644 index 0000000..b8dc69b --- /dev/null +++ b/public/images/ladill-icons/file.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/ladill-icons/folder.svg b/public/images/ladill-icons/folder.svg new file mode 100644 index 0000000..cca1317 --- /dev/null +++ b/public/images/ladill-icons/folder.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/js/app.js b/resources/js/app.js index 58be094..e700fa4 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -515,6 +515,7 @@ Alpine.data('filesManager', (config = {}) => ({ files: config.files || [], routes: config.routes || {}, csrf: config.csrf || '', + currentFolderId: config.currentFolderId || null, moveModalOpen: false, toast: '', _toastTimer: null, @@ -636,6 +637,129 @@ Alpine.data('filesManager', (config = {}) => ({ this.selected.forEach((id) => params.append('files[]', id)); window.location.href = `${this.routes.openInEmail}?${params.toString()}`; }, + + createMenuOpen: false, + folderModalOpen: false, + newFolderName: '', + uploading: false, + duplicateModalOpen: false, + duplicateFile: null, + pendingUploads: null, + pendingResolutions: {}, + duplicateQueue: [], + + triggerFileUpload() { + this.createMenuOpen = false; + this.$refs.fileUploadInput?.click(); + }, + + triggerFolderUpload() { + this.createMenuOpen = false; + this.$refs.folderUploadInput?.click(); + }, + + openFolderModal() { + this.createMenuOpen = false; + this.folderModalOpen = true; + this.newFolderName = ''; + this.$nextTick(() => this.$refs.folderNameInput?.focus()); + }, + + async handleUploadPick(event) { + const picked = Array.from(event.target.files || []); + event.target.value = ''; + if (picked.length === 0) return; + await this.processUploadQueue(picked); + }, + + async processUploadQueue(files) { + this.uploading = true; + try { + const checkRes = await fetch(this.routes.uploadCheck, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + filenames: files.map((f) => f.name), + folder: this.currentFolderId || null, + }), + }); + const checkData = await checkRes.json(); + this.pendingUploads = files; + this.pendingResolutions = {}; + this.duplicateQueue = checkData.conflicts || []; + + if (this.duplicateQueue.length > 0) { + this.showNextDuplicate(); + return; + } + + await this.submitUpload(files, {}); + } catch (e) { + this.showToast('Upload failed. Please try again.'); + } finally { + if (!this.duplicateModalOpen) { + this.uploading = false; + } + } + }, + + showNextDuplicate() { + if (this.duplicateQueue.length === 0) { + this.duplicateModalOpen = false; + this.submitUpload(this.pendingUploads, this.pendingResolutions).finally(() => { + this.uploading = false; + this.pendingUploads = null; + this.pendingResolutions = {}; + }); + return; + } + + this.duplicateFile = this.duplicateQueue.shift(); + this.duplicateModalOpen = true; + }, + + resolveDuplicate(action) { + if (!this.duplicateFile) return; + this.pendingResolutions[this.duplicateFile.filename] = action; + this.duplicateModalOpen = false; + this.duplicateFile = null; + this.showNextDuplicate(); + }, + + async submitUpload(files, resolutions) { + const formData = new FormData(); + files.forEach((file) => formData.append('files[]', file)); + if (this.currentFolderId) { + formData.append('folder', this.currentFolderId); + } + Object.entries(resolutions).forEach(([name, action]) => { + formData.append(`resolutions[${name}]`, action); + }); + + const res = await fetch(this.routes.upload, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: formData, + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + this.showToast(data.message || 'Upload failed.'); + return; + } + + this.showToast(data.message || 'Upload complete.'); + window.location.reload(); + }, })); window.Alpine = Alpine; diff --git a/resources/views/transfer/files/index.blade.php b/resources/views/transfer/files/index.blade.php index 9abb77c..dd2e3e8 100644 --- a/resources/views/transfer/files/index.blade.php +++ b/resources/views/transfer/files/index.blade.php @@ -1,43 +1,20 @@ Files @php + $iconBase = asset('images/ladill-icons'); $fmtBytes = function (int $bytes) { if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2).' GB'; if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB'; return number_format($bytes / 1024, 0).' KB'; }; - $fileIcon = function (\App\Models\TransferFile $file): string { - $mime = (string) $file->mime_type; - $ext = strtolower(pathinfo($file->original_name, PATHINFO_EXTENSION)); - if (str_starts_with($mime, 'image/') || in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], true)) { - return 'image'; - } - if (str_starts_with($mime, 'video/') || in_array($ext, ['mp4', 'mov', 'avi', 'mkv'], true)) { - return 'video'; - } - if (str_starts_with($mime, 'audio/') || in_array($ext, ['mp3', 'wav', 'aac'], true)) { - return 'audio'; - } - if (in_array($ext, ['zip', 'rar', '7z', 'tar', 'gz'], true)) { - return 'archive'; - } - if (in_array($ext, ['pdf', 'doc', 'docx', 'txt', 'rtf'], true)) { - return 'document'; - } - - return 'file'; - }; $fileRows = $files->map(fn ($file) => [ 'id' => $file->id, 'name' => $file->original_name, 'size' => $file->humanSize(), 'downloads' => $file->downloads_total, 'modified' => $file->updated_at?->format('M j, Y') ?? '—', - 'transferId' => $file->transfer_id, - 'transferTitle' => $file->transfer->title, 'shareUrl' => $file->transfer->qrCode?->publicUrl() ?? '', 'downloadUrl' => route('transfer.files.download', $file), - 'icon' => $fileIcon($file), 'shared' => $file->transfer->recipient_email !== null || $file->downloads_total > 0, ])->values(); @endphp @@ -47,15 +24,21 @@ 'fileIds' => $fileRows->pluck('id')->all(), 'files' => $fileRows->all(), 'csrf' => csrf_token(), + 'currentFolderId' => $folderTransfer?->id, 'routes' => [ 'bulkDownload' => route('transfer.files.bulk-download'), 'bulkDelete' => route('transfer.files.bulk-delete'), 'move' => route('transfer.files.move'), 'share' => route('transfer.files.share'), 'openInEmail' => route('transfer.files.open-in-email'), - 'reshareEmail' => route('transfer.files.reshare-email'), + 'uploadCheck' => route('transfer.files.upload.check'), + 'upload' => route('transfer.files.upload'), + 'createFolder' => route('transfer.files.folders.store'), ], - 'moveTargets' => $moveTargets->map(fn ($t) => ['id' => $t->id, 'title' => $t->title])->values()->all(), + 'moveTargets' => $moveTargets->map(fn ($t) => [ + 'id' => $t->id, + 'title' => $t->is_root_storage ? 'My files (root)' : $t->title, + ])->values()->all(), ]))"> {{-- Header --}} @@ -64,18 +47,51 @@

Files

{{ $fmtBytes($storageBytes) }} stored (~GHS {{ number_format($storageGb * $pricePerGb, 2) }}/month)

+ + {{-- Create or upload --}} +
+ +
+ +
+ + +
+ + +
{{-- Breadcrumbs --}} - {{-- Action toolbar --}} + {{-- Action toolbar + file list --}}