diff --git a/app/Http/Controllers/Transfer/FilesController.php b/app/Http/Controllers/Transfer/FilesController.php index d75ccd8..07e2db6 100644 --- a/app/Http/Controllers/Transfer/FilesController.php +++ b/app/Http/Controllers/Transfer/FilesController.php @@ -63,6 +63,7 @@ class FilesController extends Controller ->where('user_id', $account->id) ->where('is_folder', true) ->where('status', '!=', Transfer::STATUS_DELETED) + ->with('qrCode') ->withCount('files') ->when($search !== '', fn ($q) => $q->where('title', 'like', '%'.$search.'%')) ->orderBy('title') @@ -111,12 +112,13 @@ class FilesController extends Controller public function bulkDownload(Request $request): StreamedResponse|RedirectResponse { $files = $this->authorizedFiles($request->input('files', [])); + $folders = $this->authorizedFolders($request->input('folders', [])); - if ($files->isEmpty()) { - return back()->with('error', 'Select at least one file to download.'); + if ($files->isEmpty() && $folders->isEmpty()) { + return back()->with('error', 'Select at least one file or folder to download.'); } - if ($files->count() === 1) { + if ($files->count() === 1 && $folders->isEmpty()) { $file = $files->first(); abort_unless($file->existsOnDisk(), 404); @@ -127,38 +129,39 @@ class FilesController extends Controller ); } - $filename = 'ladill-files-'.now()->format('Y-m-d-His').'.zip'; + if ($folders->count() === 1 && $files->isEmpty()) { + $folder = $folders->first(); - return response()->streamDownload(function () use ($files) { - $zip = new ZipArchive; - $tmp = tempnam(sys_get_temp_dir(), 'ladill_zip_'); - if ($tmp === false || $zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - throw new RuntimeException('Could not prepare download archive.'); + return $this->streamFolderZip($folder, $folder->title.'.zip'); + } + + $entries = $this->zipEntriesForFiles($files); + + foreach ($folders as $folder) { + $prefix = $folders->count() > 1 ? $folder->title.'/' : ''; + foreach ($folder->files as $file) { + $entries[] = [ + 'zipPath' => $prefix.$file->original_name, + 'file' => $file, + ]; } + } - $usedNames = []; - foreach ($files as $file) { - if (! $file->existsOnDisk()) { - continue; - } + return $this->streamZipEntries( + $entries, + 'ladill-files-'.now()->format('Y-m-d-His').'.zip', + ); + } - $name = $file->original_name; - if (isset($usedNames[$name])) { - $usedNames[$name]++; - $ext = pathinfo($name, PATHINFO_EXTENSION); - $base = pathinfo($name, PATHINFO_FILENAME); - $name = $base.' ('.$usedNames[$name].')'.($ext !== '' ? '.'.$ext : ''); - } else { - $usedNames[$name] = 0; - } + public function downloadFolder(Transfer $folder): StreamedResponse|RedirectResponse + { + $this->authorizeFolder($folder); - $zip->addFromString($name, Storage::disk($file->disk)->get($file->path)); - } + if ($folder->files()->count() === 0) { + return back()->with('error', 'This folder is empty.'); + } - $zip->close(); - readfile($tmp); - @unlink($tmp); - }, $filename, ['Content-Type' => 'application/zip']); + return $this->streamFolderZip($folder, $folder->title.'.zip'); } public function destroy(TransferFile $file): RedirectResponse @@ -209,13 +212,15 @@ class FilesController extends Controller public function share(Request $request): JsonResponse { $files = $this->authorizedFiles($request->input('files', [])); + $folders = $this->authorizedFolders($request->input('folders', [])); - if ($files->isEmpty()) { - return response()->json(['message' => 'Select at least one file to share.'], 422); + if ($files->isEmpty() && $folders->isEmpty()) { + return response()->json(['message' => 'Select at least one file or folder to share.'], 422); } $links = $files ->map(fn (TransferFile $file) => $file->transfer->qrCode?->publicUrl()) + ->merge($folders->map(fn (Transfer $folder) => $folder->qrCode?->publicUrl())) ->filter() ->unique() ->values() @@ -440,4 +445,100 @@ class FilesController extends Controller 403, ); } + + private function authorizeFolder(Transfer $folder): void + { + abort_unless( + $folder->user_id === ladill_account()->id + && $folder->is_folder + && $folder->status !== Transfer::STATUS_DELETED, + 403, + ); + } + + /** @param array|null $ids */ + private function authorizedFolders(?array $ids): Collection + { + $ids = collect($ids ?? []) + ->filter(fn ($id) => is_numeric($id)) + ->map(fn ($id) => (int) $id) + ->unique() + ->values() + ->all(); + + if ($ids === []) { + return collect(); + } + + return Transfer::query() + ->whereIn('id', $ids) + ->where('user_id', ladill_account()->id) + ->where('is_folder', true) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->with(['files', 'qrCode']) + ->get(); + } + + private function streamFolderZip(Transfer $folder, string $filename): StreamedResponse + { + $folder->loadMissing('files'); + + return $this->streamZipEntries( + $this->zipEntriesForFiles($folder->files), + $filename, + ); + } + + /** @param Collection|iterable $files + * @return list + */ + private function zipEntriesForFiles(iterable $files): array + { + $entries = []; + + foreach ($files as $file) { + $entries[] = [ + 'zipPath' => $file->original_name, + 'file' => $file, + ]; + } + + return $entries; + } + + /** @param list $entries */ + private function streamZipEntries(array $entries, string $filename): StreamedResponse + { + return response()->streamDownload(function () use ($entries) { + $zip = new ZipArchive; + $tmp = tempnam(sys_get_temp_dir(), 'ladill_zip_'); + if ($tmp === false || $zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Could not prepare download archive.'); + } + + $usedNames = []; + foreach ($entries as $entry) { + $file = $entry['file']; + if (! $file->existsOnDisk()) { + continue; + } + + $name = $entry['zipPath']; + if (isset($usedNames[$name])) { + $usedNames[$name]++; + $ext = pathinfo($name, PATHINFO_EXTENSION); + $base = pathinfo($name, PATHINFO_FILENAME); + $name = $base.' ('.$usedNames[$name].')'.($ext !== '' ? '.'.$ext : ''); + } else { + $usedNames[$name] = 0; + } + + $zip->addFromString($name, Storage::disk($file->disk)->get($file->path)); + } + + $zip->close(); + readfile($tmp); + @unlink($tmp); + }, $filename, ['Content-Type' => 'application/zip']); + } } diff --git a/resources/js/app.js b/resources/js/app.js index 87de3e5..f5cf728 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -510,9 +510,12 @@ Alpine.data('transferCreateForm', (config = {}) => ({ })); Alpine.data('filesManager', (config = {}) => ({ - selected: [], + selectedFileIds: [], + selectedFolderIds: [], fileIds: config.fileIds || [], files: config.files || [], + folderIds: config.folderIds || [], + folders: config.folders || [], routes: config.routes || {}, csrf: config.csrf || '', currentFolderId: config.currentFolderId || null, @@ -520,20 +523,38 @@ Alpine.data('filesManager', (config = {}) => ({ toast: '', _toastTimer: null, + get selectedCount() { + return this.selectedFileIds.length + this.selectedFolderIds.length; + }, + get allSelected() { - return this.fileIds.length > 0 && this.selected.length === this.fileIds.length; + const total = this.fileIds.length + this.folderIds.length; + return total > 0 + && this.selectedFileIds.length === this.fileIds.length + && this.selectedFolderIds.length === this.folderIds.length; }, toggleAll(event) { - this.selected = event.target.checked ? [...this.fileIds] : []; + if (event.target.checked) { + this.selectedFileIds = [...this.fileIds]; + this.selectedFolderIds = [...this.folderIds]; + } else { + this.selectedFileIds = []; + this.selectedFolderIds = []; + } }, clearSelection() { - this.selected = []; + this.selectedFileIds = []; + this.selectedFolderIds = []; }, - selectedFiles() { - return this.files.filter((file) => this.selected.includes(file.id)); + selectedFileRows() { + return this.files.filter((file) => this.selectedFileIds.includes(file.id)); + }, + + selectedFolderRows() { + return this.folders.filter((folder) => this.selectedFolderIds.includes(folder.id)); }, showToast(message) { @@ -554,7 +575,7 @@ Alpine.data('filesManager', (config = {}) => ({ csrf.value = this.csrf; form.appendChild(csrf); - this.selected.forEach((id) => { + this.selectedFileIds.forEach((id) => { const input = document.createElement('input'); input.type = 'hidden'; input.name = 'files[]'; @@ -562,6 +583,14 @@ Alpine.data('filesManager', (config = {}) => ({ form.appendChild(input); }); + this.selectedFolderIds.forEach((id) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'folders[]'; + input.value = id; + form.appendChild(input); + }); + Object.entries(extra).forEach(([key, value]) => { const input = document.createElement('input'); input.type = 'hidden'; @@ -575,27 +604,41 @@ Alpine.data('filesManager', (config = {}) => ({ }, downloadSelected() { - if (this.selected.length === 0) return; - if (this.selected.length === 1) { - const file = this.selectedFiles()[0]; + if (this.selectedCount === 0) return; + + if (this.selectedFileIds.length === 1 && this.selectedFolderIds.length === 0) { + const file = this.selectedFileRows()[0]; if (file?.downloadUrl) { window.location.href = file.downloadUrl; } return; } + + if (this.selectedFolderIds.length === 1 && this.selectedFileIds.length === 0) { + const folder = this.selectedFolderRows()[0]; + if (folder?.downloadUrl) { + window.location.href = folder.downloadUrl; + } + return; + } + this.submitAction(this.routes.bulkDownload); }, async deleteSelected() { - if (this.selected.length === 0) return; - if (! confirm(`Delete ${this.selected.length} file${this.selected.length === 1 ? '' : 's'}? This cannot be undone.`)) { + if (this.selectedFileIds.length === 0) return; + if (this.selectedFolderIds.length > 0) { + this.showToast('Delete files individually. Folder delete is not supported yet.'); + return; + } + if (! confirm(`Delete ${this.selectedFileIds.length} file${this.selectedFileIds.length === 1 ? '' : 's'}? This cannot be undone.`)) { return; } this.submitAction(this.routes.bulkDelete); }, async shareSelected() { - if (this.selected.length === 0) return; + if (this.selectedCount === 0) return; try { const res = await fetch(this.routes.share, { method: 'POST', @@ -605,12 +648,17 @@ Alpine.data('filesManager', (config = {}) => ({ 'X-CSRF-TOKEN': this.csrf, 'X-Requested-With': 'XMLHttpRequest', }, - body: JSON.stringify({ files: this.selected }), + body: JSON.stringify({ + files: this.selectedFileIds, + folders: this.selectedFolderIds, + }), }); const data = await res.json(); if (data.links?.length) { await navigator.clipboard.writeText(data.links.join('\n')); this.showToast(data.message || 'Share links copied.'); + } else { + this.showToast(data.message || 'No share links available.'); } } catch (e) { this.showToast('Could not copy share links.'); @@ -618,23 +666,31 @@ Alpine.data('filesManager', (config = {}) => ({ }, async copyLinks() { - const links = [...new Set(this.selectedFiles().map((f) => f.shareUrl).filter(Boolean))]; - if (links.length === 0) { + const links = [ + ...this.selectedFileRows().map((f) => f.shareUrl), + ...this.selectedFolderRows().map((f) => f.shareUrl), + ].filter(Boolean); + const uniqueLinks = [...new Set(links)]; + if (uniqueLinks.length === 0) { this.showToast('No share links available.'); return; } try { - await navigator.clipboard.writeText(links.join('\n')); - this.showToast(links.length === 1 ? 'Link copied.' : `${links.length} links copied.`); + await navigator.clipboard.writeText(uniqueLinks.join('\n')); + this.showToast(uniqueLinks.length === 1 ? 'Link copied.' : `${uniqueLinks.length} links copied.`); } catch (e) { this.showToast('Could not copy links.'); } }, openInEmail() { - if (this.selected.length === 0) return; + if (this.selectedFileIds.length === 0) return; + if (this.selectedFolderIds.length > 0) { + this.showToast('Open in email works with files only.'); + return; + } const params = new URLSearchParams(); - this.selected.forEach((id) => params.append('files[]', id)); + this.selectedFileIds.forEach((id) => params.append('files[]', id)); window.location.href = `${this.routes.openInEmail}?${params.toString()}`; }, diff --git a/resources/views/transfer/files/index.blade.php b/resources/views/transfer/files/index.blade.php index 8a4bfa7..a2d1e46 100644 --- a/resources/views/transfer/files/index.blade.php +++ b/resources/views/transfer/files/index.blade.php @@ -17,12 +17,24 @@ 'downloadUrl' => route('transfer.files.download', $file), 'shared' => $file->transfer->recipient_email !== null || $file->downloads_total > 0, ])->values(); + $folderRows = $folders->map(fn ($folder) => [ + 'id' => $folder->id, + 'name' => $folder->title, + 'itemCount' => (int) $folder->files_count, + 'modified' => $folder->updated_at?->format('M j, Y') ?? '—', + 'shareUrl' => $folder->qrCode?->publicUrl() ?? '', + 'downloadUrl' => route('transfer.files.folders.download', $folder), + 'openUrl' => route('transfer.files.index', ['folder' => $folder->id]), + 'shared' => $folder->recipient_email !== null || $folder->downloads_total > 0, + ])->values(); @endphp
-