diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 4d8bd9d..0499350 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers; -use App\Models\QrCode; -use App\Support\Qr\QrTypeCatalog; +use App\Models\Transfer; +use App\Models\TransferFile; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -28,28 +28,49 @@ class SearchController extends Controller /** @return list */ private function results(string $q): array { + $account = ladill_account(); $like = '%'.$q.'%'; + $results = []; - return ladill_account()->qrCodes() - ->whereIn('type', QrTypeCatalog::eventsTypes()) - ->where(function ($query) use ($like): void { - $query->where('label', 'like', $like) - ->orWhere('short_code', 'like', $like) - ->orWhere('destination_url', 'like', $like); + Transfer::query() + ->where('user_id', $account->id) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->where(function ($query) use ($like) { + $query->where('title', 'like', $like) + ->orWhere('message', 'like', $like) + ->orWhere('recipient_email', 'like', $like); }) - ->orderByDesc('updated_at') - ->limit(15) + ->withCount('files') + ->latest() + ->limit(8) ->get() - ->map(function (QrCode $code): array { - $content = $code->content(); - - return [ - 'type' => $code->type === QrCode::TYPE_EVENT ? 'event' : 'programme', - 'title' => $content['name'] ?? $code->label, - 'subtitle' => $code->typeLabel().' · '.$code->publicUrl(), - 'url' => route('events.show', $code), + ->each(function (Transfer $transfer) use (&$results) { + $results[] = [ + 'type' => 'transfer', + 'title' => $transfer->title, + 'subtitle' => $transfer->files_count.' file'.($transfer->files_count === 1 ? '' : 's').' · '.$transfer->billingStatusLabel(), + 'url' => route('transfer.transfers.show', $transfer), ]; - }) - ->all(); + }); + + TransferFile::query() + ->whereHas('transfer', fn ($inner) => $inner + ->where('user_id', $account->id) + ->where('status', '!=', Transfer::STATUS_DELETED)) + ->where('original_name', 'like', $like) + ->with('transfer') + ->latest() + ->limit(8) + ->get() + ->each(function (TransferFile $file) use (&$results) { + $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]), + ]; + }); + + return array_slice($results, 0, 15); } } diff --git a/app/Http/Controllers/Transfer/FilesController.php b/app/Http/Controllers/Transfer/FilesController.php index aebc755..615f645 100644 --- a/app/Http/Controllers/Transfer/FilesController.php +++ b/app/Http/Controllers/Transfer/FilesController.php @@ -5,33 +5,302 @@ namespace App\Http\Controllers\Transfer; use App\Http\Controllers\Controller; use App\Models\Transfer; use App\Models\TransferFile; +use App\Services\Transfer\TransferRecipientMailService; +use App\Services\Transfer\TransferService; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Storage; use Illuminate\View\View; +use RuntimeException; +use Symfony\Component\HttpFoundation\StreamedResponse; +use ZipArchive; class FilesController extends Controller { + public function __construct( + private TransferService $transfers, + ) {} + public function index(Request $request): View { $account = ladill_account(); + $search = trim((string) $request->query('q', '')); + $folderId = $request->integer('folder') ?: null; + $sort = (string) $request->query('sort', 'newest'); + $viewMode = (string) $request->query('view', $folderId ? 'files' : 'all'); - $files = TransferFile::query() + $folderTransfer = null; + if ($folderId) { + $folderTransfer = Transfer::query() + ->where('user_id', $account->id) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->findOrFail($folderId); + } + + $filesQuery = TransferFile::query() ->whereHas('transfer', fn ($q) => $q ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE)) - ->with('transfer') - ->latest() - ->paginate(30); + ->where('status', '!=', Transfer::STATUS_DELETED)) + ->when($folderTransfer, fn ($q) => $q->where('transfer_id', $folderTransfer->id)) + ->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%')) + ->with(['transfer.qrCode']) + ->when($sort === 'oldest', fn ($q) => $q->oldest()) + ->when($sort === 'name', fn ($q) => $q->orderBy('original_name')) + ->when($sort === 'size', fn ($q) => $q->orderByDesc('size_bytes')) + ->when($sort === 'downloads', fn ($q) => $q->orderByDesc('downloads_total')) + ->when($sort === 'newest', fn ($q) => $q->latest()); + + $files = $filesQuery->paginate(30)->withQueryString(); + + $folders = Transfer::query() + ->where('user_id', $account->id) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->whereHas('files') + ->withCount('files') + ->when($search !== '', fn ($q) => $q->where('title', 'like', '%'.$search.'%')) + ->orderBy('title') + ->get(); + + $moveTargets = Transfer::query() + ->where('user_id', $account->id) + ->accessible() + ->orderBy('title') + ->get(['id', 'title']); $storageBytes = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE) + ->where('status', '!=', Transfer::STATUS_DELETED) ->sum('storage_bytes'); return view('transfer.files.index', [ 'files' => $files, + 'folders' => $folders, + 'folderTransfer' => $folderTransfer, + 'moveTargets' => $moveTargets, + 'search' => $search, + 'sort' => $sort, + 'viewMode' => $viewMode, 'storageBytes' => (int) $storageBytes, 'storageGb' => round($storageBytes / (1024 * 1024 * 1024), 2), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), ]); } + + public function download(TransferFile $file): StreamedResponse + { + $this->authorizeFile($file); + abort_unless($file->existsOnDisk(), 404); + + return Storage::disk($file->disk)->response( + $file->path, + $file->original_name, + ['Content-Type' => $file->mime_type], + ); + } + + public function bulkDownload(Request $request): StreamedResponse|RedirectResponse + { + $files = $this->authorizedFiles($request->input('files', [])); + + if ($files->isEmpty()) { + return back()->with('error', 'Select at least one file to download.'); + } + + if ($files->count() === 1) { + $file = $files->first(); + abort_unless($file->existsOnDisk(), 404); + + return Storage::disk($file->disk)->response( + $file->path, + $file->original_name, + ['Content-Type' => $file->mime_type], + ); + } + + $filename = 'ladill-files-'.now()->format('Y-m-d-His').'.zip'; + + 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.'); + } + + $usedNames = []; + foreach ($files as $file) { + if (! $file->existsOnDisk()) { + continue; + } + + $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; + } + + $zip->addFromString($name, Storage::disk($file->disk)->get($file->path)); + } + + $zip->close(); + readfile($tmp); + @unlink($tmp); + }, $filename, ['Content-Type' => 'application/zip']); + } + + public function destroy(TransferFile $file): RedirectResponse + { + $this->authorizeFile($file); + $this->transfers->deleteFile($file); + + return back()->with('success', 'File deleted.'); + } + + public function bulkDestroy(Request $request): RedirectResponse + { + $files = $this->authorizedFiles($request->input('files', [])); + + if ($files->isEmpty()) { + return back()->with('error', 'Select at least one file to delete.'); + } + + $this->transfers->deleteFiles($files->all()); + + return back()->with('success', $files->count().' file'.($files->count() === 1 ? '' : 's').' deleted.'); + } + + public function move(Request $request): RedirectResponse + { + $data = $request->validate([ + 'destination' => 'required|integer|exists:transfers,id', + 'files' => 'required|array|min:1', + 'files.*' => 'integer|exists:transfer_files,id', + ]); + + $destination = Transfer::query() + ->where('user_id', ladill_account()->id) + ->accessible() + ->findOrFail($data['destination']); + + $files = $this->authorizedFiles($data['files']); + $this->transfers->moveFiles($destination, $files->all()); + + return redirect() + ->route('transfer.files.index', ['folder' => $destination->id]) + ->with('success', $files->count().' file'.($files->count() === 1 ? '' : 's').' moved to '.$destination->title.'.'); + } + + public function share(Request $request): JsonResponse + { + $files = $this->authorizedFiles($request->input('files', [])); + + if ($files->isEmpty()) { + return response()->json(['message' => 'Select at least one file to share.'], 422); + } + + $links = $files + ->map(fn (TransferFile $file) => $file->transfer->qrCode?->publicUrl()) + ->filter() + ->unique() + ->values() + ->all(); + + return response()->json([ + 'links' => $links, + 'message' => count($links).' share link'.(count($links) === 1 ? '' : 's').' ready to copy.', + ]); + } + + public function reshareEmail(Request $request): RedirectResponse + { + $data = $request->validate([ + 'transfer' => 'required|integer|exists:transfers,id', + 'email' => 'required|email|max:255', + ]); + + $transfer = Transfer::query() + ->where('user_id', ladill_account()->id) + ->where('status', '!=', Transfer::STATUS_DELETED) + ->findOrFail($data['transfer']); + + $transfer->forceFill([ + 'recipient_email' => $data['email'], + 'recipient_email_milestones' => ['created'], + 'recipient_milestones_sent' => [], + ])->save(); + + app(TransferRecipientMailService::class)->notifyRecipient($transfer->fresh(['files', 'qrCode', 'user']), 'created'); + + return back()->with('success', 'Share link emailed to '.$data['email'].'.'); + } + + public function openInEmail(Request $request): RedirectResponse + { + $files = $this->authorizedFiles($request->input('files', [])); + + if ($files->isEmpty()) { + return back()->with('error', 'Select at least one file to open in email.'); + } + + $links = $files + ->map(fn (TransferFile $file) => $file->transfer->qrCode?->publicUrl()) + ->filter() + ->unique() + ->values(); + + $names = $files->pluck('original_name')->unique()->take(3)->implode(', '); + if ($files->count() > 3) { + $names .= ' +'.($files->count() - 3).' more'; + } + + $body = "Hi,\n\nI'm sharing the following files with you via Ladill Transfer:\n\n"; + foreach ($links as $link) { + $body .= $link."\n"; + } + $body .= "\nDownload using the link".($links->count() === 1 ? '' : 's')." above."; + + $url = ladill_webmail_url('').'?'.http_build_query([ + 'compose' => 1, + 'subject' => 'Shared files: '.$names, + 'body' => $body, + ]); + + return redirect()->away($url); + } + + /** @param array|null $ids */ + private function authorizedFiles(?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 TransferFile::query() + ->whereIn('id', $ids) + ->whereHas('transfer', fn ($q) => $q + ->where('user_id', ladill_account()->id) + ->where('status', '!=', Transfer::STATUS_DELETED)) + ->with(['transfer.qrCode']) + ->get(); + } + + private function authorizeFile(TransferFile $file): void + { + abort_unless( + $file->transfer()->where('user_id', ladill_account()->id)->exists(), + 403, + ); + } } diff --git a/app/Http/Controllers/Transfer/TransferController.php b/app/Http/Controllers/Transfer/TransferController.php index 541e78f..6b85bc5 100644 --- a/app/Http/Controllers/Transfer/TransferController.php +++ b/app/Http/Controllers/Transfer/TransferController.php @@ -28,15 +28,34 @@ class TransferController extends Controller public function index(Request $request): View { $account = ladill_account(); + $search = trim((string) $request->query('q', '')); + $status = (string) $request->query('status', 'all'); + $sort = (string) $request->query('sort', 'newest'); $transfers = Transfer::query() ->where('user_id', $account->id) - ->accessible() + ->where('status', '!=', Transfer::STATUS_DELETED) + ->when($search !== '', function ($query) use ($search) { + $like = '%'.$search.'%'; + $query->where(function ($inner) use ($like) { + $inner->where('title', 'like', $like) + ->orWhere('message', 'like', $like) + ->orWhere('recipient_email', 'like', $like) + ->orWhereHas('files', fn ($files) => $files->where('original_name', 'like', $like)); + }); + }) + ->when($status === 'active', fn ($query) => $query->accessible()) + ->when($status === 'grace', fn ($query) => $query->where('status', Transfer::STATUS_GRACE)) + ->when($status === 'expired', fn ($query) => $query->where('status', Transfer::STATUS_EXPIRED)) ->with(['qrCode', 'files']) - ->latest() - ->paginate(20); + ->when($sort === 'oldest', fn ($query) => $query->oldest()) + ->when($sort === 'title', fn ($query) => $query->orderBy('title')) + ->when($sort === 'downloads', fn ($query) => $query->orderByDesc('downloads_total')) + ->when($sort === 'newest', fn ($query) => $query->latest()) + ->paginate(20) + ->withQueryString(); - return view('transfer.transfers.index', compact('transfers')); + return view('transfer.transfers.index', compact('transfers', 'search', 'status', 'sort')); } public function create(): View diff --git a/app/Services/Transfer/TransferService.php b/app/Services/Transfer/TransferService.php index 9b12d0e..47c30d3 100644 --- a/app/Services/Transfer/TransferService.php +++ b/app/Services/Transfer/TransferService.php @@ -197,4 +197,65 @@ class TransferService $transfer->update(['status' => Transfer::STATUS_DELETED]); }); } + + public function deleteFile(TransferFile $file): void + { + DB::transaction(function () use ($file) { + $transfer = $file->transfer()->lockForUpdate()->first(); + Storage::disk($file->disk)->delete($file->path); + $size = $file->size_bytes; + $file->delete(); + + $transfer->decrement('storage_bytes', $size); + + if ($transfer->files()->count() === 0) { + $this->delete($transfer->fresh(['files', 'qrCode'])); + } + }); + } + + /** @param list $files */ + public function deleteFiles(array $files): void + { + foreach ($files as $file) { + $this->deleteFile($file); + } + } + + /** @param list $files */ + public function moveFiles(Transfer $destination, array $files): void + { + if ($files === []) { + return; + } + + DB::transaction(function () use ($destination, $files) { + $destination = Transfer::query()->lockForUpdate()->findOrFail($destination->id); + $movedBytes = 0; + $sourceAdjustments = []; + + foreach ($files as $file) { + if ($file->transfer_id === $destination->id) { + continue; + } + + $sourceId = $file->transfer_id; + $movedBytes += $file->size_bytes; + $sourceAdjustments[$sourceId] = ($sourceAdjustments[$sourceId] ?? 0) + $file->size_bytes; + $file->update(['transfer_id' => $destination->id]); + } + + 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) { + $this->delete($source->fresh(['files', 'qrCode'])); + } + } + + if ($movedBytes > 0) { + $destination->increment('storage_bytes', $movedBytes); + } + }); + } } diff --git a/app/Support/helpers.php b/app/Support/helpers.php index 75163b0..5d5d460 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -23,6 +23,15 @@ if (! function_exists('ladill_domains_url')) { } } +if (! function_exists('ladill_webmail_url')) { + function ladill_webmail_url(string $path = ''): string + { + $base = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/'); + + return $path !== '' ? $base.'/'.ltrim($path, '/') : $base; + } +} + if (! function_exists('ladill_account_url')) { function ladill_account_url(string $path = ''): string { diff --git a/resources/js/app.js b/resources/js/app.js index b54b7f3..58be094 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -509,6 +509,135 @@ Alpine.data('transferCreateForm', (config = {}) => ({ }, })); +Alpine.data('filesManager', (config = {}) => ({ + selected: [], + fileIds: config.fileIds || [], + files: config.files || [], + routes: config.routes || {}, + csrf: config.csrf || '', + moveModalOpen: false, + toast: '', + _toastTimer: null, + + get allSelected() { + return this.fileIds.length > 0 && this.selected.length === this.fileIds.length; + }, + + toggleAll(event) { + this.selected = event.target.checked ? [...this.fileIds] : []; + }, + + clearSelection() { + this.selected = []; + }, + + selectedFiles() { + return this.files.filter((file) => this.selected.includes(file.id)); + }, + + showToast(message) { + this.toast = message; + clearTimeout(this._toastTimer); + this._toastTimer = setTimeout(() => { this.toast = ''; }, 2800); + }, + + submitAction(url, extra = {}) { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = url; + form.style.display = 'none'; + + const csrf = document.createElement('input'); + csrf.type = 'hidden'; + csrf.name = '_token'; + csrf.value = this.csrf; + form.appendChild(csrf); + + this.selected.forEach((id) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'files[]'; + input.value = id; + form.appendChild(input); + }); + + Object.entries(extra).forEach(([key, value]) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = key; + input.value = value; + form.appendChild(input); + }); + + document.body.appendChild(form); + form.submit(); + }, + + downloadSelected() { + if (this.selected.length === 0) return; + if (this.selected.length === 1) { + const file = this.selectedFiles()[0]; + if (file?.downloadUrl) { + window.location.href = file.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.`)) { + return; + } + this.submitAction(this.routes.bulkDelete); + }, + + async shareSelected() { + if (this.selected.length === 0) return; + try { + const res = await fetch(this.routes.share, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrf, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ files: this.selected }), + }); + const data = await res.json(); + if (data.links?.length) { + await navigator.clipboard.writeText(data.links.join('\n')); + this.showToast(data.message || 'Share links copied.'); + } + } catch (e) { + this.showToast('Could not copy share links.'); + } + }, + + async copyLinks() { + const links = [...new Set(this.selectedFiles().map((f) => f.shareUrl).filter(Boolean))]; + if (links.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.`); + } catch (e) { + this.showToast('Could not copy links.'); + } + }, + + openInEmail() { + if (this.selected.length === 0) return; + const params = new URLSearchParams(); + this.selected.forEach((id) => params.append('files[]', id)); + window.location.href = `${this.routes.openInEmail}?${params.toString()}`; + }, +})); + window.Alpine = Alpine; registerLadillConfirmStore(Alpine); diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index 95acdcd..5521374 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -65,8 +65,8 @@ @include('partials.mobile-bottom-nav', [ 'homeUrl' => route('transfer.dashboard'), 'homeActive' => request()->routeIs('transfer.dashboard'), - 'searchUrl' => route('transfer.transfers.index'), - 'searchActive' => request()->routeIs('transfer.transfers.*'), + 'searchUrl' => route('transfer.search'), + 'searchActive' => request()->routeIs('transfer.search'), 'notificationsUrl' => route('notifications.index'), 'notificationsActive' => request()->routeIs('notifications.*'), 'unreadUrl' => route('notifications.unread'), diff --git a/resources/views/partials/topbar-qr.blade.php b/resources/views/partials/topbar-qr.blade.php index 5ef80f7..f7febc1 100644 --- a/resources/views/partials/topbar-qr.blade.php +++ b/resources/views/partials/topbar-qr.blade.php @@ -13,6 +13,58 @@ @include('partials.mobile-topbar-title') + {{-- Search --}} +
diff --git a/resources/views/search/index.blade.php b/resources/views/search/index.blade.php index b9503b7..d4c143e 100644 --- a/resources/views/search/index.blade.php +++ b/resources/views/search/index.blade.php @@ -4,10 +4,10 @@ @include('partials.search-screen', [ 'query' => $query, 'results' => $results, - 'backUrl' => route('events.dashboard'), - 'searchUrl' => route('events.search'), - 'heading' => 'Find events and programmes', - 'placeholder' => 'Search events, programmes, short codes…', - 'emptyHint' => 'Use at least 2 characters to search event names, programme titles, or short codes.', + 'backUrl' => route('transfer.dashboard'), + 'searchUrl' => route('transfer.search'), + 'heading' => 'Find transfers and files', + 'placeholder' => 'Search transfers, file names, recipients…', + 'emptyHint' => 'Use at least 2 characters to search transfer titles, file names, or recipient emails.', ]) diff --git a/resources/views/transfer/files/index.blade.php b/resources/views/transfer/files/index.blade.php index 1359da9..9abb77c 100644 --- a/resources/views/transfer/files/index.blade.php +++ b/resources/views/transfer/files/index.blade.php @@ -6,43 +6,243 @@ 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 -
-
-

Files

-

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

+ +
+ + {{-- Header --}} +
+
+

Files

+

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

+
- @if($files->isEmpty()) -
- No stored files yet. + {{-- Breadcrumbs --}} + + + {{-- Action toolbar --}} +
+
+ + + +
+
+ @if($folderTransfer) + + @endif + + +
+
- @else -
+ + {{-- Folders row (when at root) --}} + @if(! $folderTransfer && $folders->isNotEmpty() && $search === '') +
+

Folders

+
+ @foreach($folders as $folder) + + + {{ $folder->title }} + {{ $folder->files_count }} + + @endforeach +
+
+ @endif + + @if($files->isEmpty()) +
+ @if($search !== '') + No files match your search. + @elseif($folderTransfer) + This folder is empty. + @else + No stored files yet. + @endif +
+ @else - - - - + + + + + + @unless($folderTransfer) + + @endunless @foreach($files as $file) - - - + - - + + + + + @unless($folderTransfer) + + @endunless @endforeach
FileTransferSizeDownloads + + NameSize
{{ $file->original_name }} - {{ $file->transfer->title }} + @php $icon = $fileIcon($file); @endphp +
+ {{ $file->humanSize() }}{{ number_format($file->downloads_total) }} +
+ @include('transfer.files.partials.file-icon', ['icon' => $icon]) +
+

{{ $file->original_name }}

+
+ {{ $file->humanSize() }} +
+
+
+ + + +
+
+
{{ $file->humanSize() }}
-
+
+ {{ $files->total() }} file{{ $files->total() === 1 ? '' : 's' }} + Showing {{ $files->firstItem() }}–{{ $files->lastItem() }} +
+ @endif +
+ + @if($files->hasPages())
{{ $files->links() }}
@endif + + {{-- Move modal --}} +
+
+

Move to folder

+

Group selected files into another transfer folder.

+
+ @csrf + + +
+ + +
+
+
+
+ + {{-- Toast --}} +
diff --git a/resources/views/transfer/files/partials/file-icon.blade.php b/resources/views/transfer/files/partials/file-icon.blade.php new file mode 100644 index 0000000..4aedc4a --- /dev/null +++ b/resources/views/transfer/files/partials/file-icon.blade.php @@ -0,0 +1,19 @@ +@php + $colors = match ($icon) { + 'image' => 'bg-pink-100 text-pink-600', + 'video' => 'bg-violet-100 text-violet-600', + 'audio' => 'bg-cyan-100 text-cyan-600', + 'archive' => 'bg-amber-100 text-amber-600', + 'document' => 'bg-blue-100 text-blue-600', + default => 'bg-slate-100 text-slate-600', + }; +@endphp + + @if($icon === 'image') + + @elseif($icon === 'archive') + + @else + + @endif + diff --git a/resources/views/transfer/transfers/index.blade.php b/resources/views/transfer/transfers/index.blade.php index 35e1ba2..ac20f3e 100644 --- a/resources/views/transfer/transfers/index.blade.php +++ b/resources/views/transfer/transfers/index.blade.php @@ -4,22 +4,57 @@

Transfers

-

Share files with a link and QR code.

+

All your file shares — active, grace, and expired.

New transfer
+
+
+ + +
+ + + + @if($search !== '' || $status !== 'all' || $sort !== 'newest') + Clear + @endif +
+ @if($transfers->isEmpty())
-

No active transfers yet.

+

+ @if($search !== '' || $status !== 'all') + No transfers match your filters. + @else + No transfers yet. + @endif +

Create your first transfer
@else
+
+ {{ $transfers->total() }} transfer{{ $transfers->total() === 1 ? '' : 's' }} + Showing {{ $transfers->firstItem() }}–{{ $transfers->lastItem() }} +
+ @@ -28,10 +63,25 @@ @foreach($transfers as $transfer) + @php + $statusClass = match (true) { + $transfer->isPaid() => 'bg-emerald-50 text-emerald-700', + $transfer->isInGracePeriod() => 'bg-amber-50 text-amber-700', + default => 'bg-slate-100 text-slate-600', + }; + @endphp + diff --git a/routes/web.php b/routes/web.php index 1ab123c..4922ed6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Public\TransferPublicController; use App\Http\Controllers\Qr\AccountController; use App\Http\Controllers\Qr\AfiaController; use App\Http\Controllers\Qr\TeamController; +use App\Http\Controllers\SearchController; use App\Http\Controllers\Transfer\AnalyticsController; use App\Http\Controllers\Transfer\ChunkedUploadController; use App\Http\Controllers\Transfer\FilesController; @@ -39,6 +40,7 @@ Route::middleware(['auth'])->group(function () { Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read'); Route::get('/dashboard', [OverviewController::class, 'index'])->name('transfer.dashboard'); + Route::get('/search', SearchController::class)->name('transfer.search'); Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('transfer.afia.chat'); Route::get('/transfers', [TransferController::class, 'index'])->name('transfer.transfers.index'); @@ -53,6 +55,14 @@ Route::middleware(['auth'])->group(function () { Route::get('/transfers/{transfer}/download/{format}', [TransferController::class, 'download'])->name('transfer.transfers.download')->whereIn('format', ['png', 'svg', 'pdf']); Route::get('/files', [FilesController::class, 'index'])->name('transfer.files.index'); + Route::get('/files/open-in-email', [FilesController::class, 'openInEmail'])->name('transfer.files.open-in-email'); + Route::post('/files/bulk-download', [FilesController::class, 'bulkDownload'])->name('transfer.files.bulk-download'); + Route::post('/files/bulk-delete', [FilesController::class, 'bulkDestroy'])->name('transfer.files.bulk-delete'); + Route::post('/files/move', [FilesController::class, 'move'])->name('transfer.files.move'); + Route::post('/files/share', [FilesController::class, 'share'])->name('transfer.files.share'); + Route::post('/files/reshare-email', [FilesController::class, 'reshareEmail'])->name('transfer.files.reshare-email'); + Route::get('/files/{file}/download', [FilesController::class, 'download'])->name('transfer.files.download'); + Route::delete('/files/{file}', [FilesController::class, 'destroy'])->name('transfer.files.destroy'); Route::get('/analytics', [AnalyticsController::class, 'index'])->name('transfer.analytics.index'); Route::get('/wallet', [AccountController::class, 'wallet'])->name('account.wallet'); diff --git a/tests/Feature/TransferFilesTest.php b/tests/Feature/TransferFilesTest.php new file mode 100644 index 0000000..dd96a06 --- /dev/null +++ b/tests/Feature/TransferFilesTest.php @@ -0,0 +1,113 @@ +actingAs($user)->post(route('transfer.transfers.store'), [ + 'title' => $title, + 'files' => [UploadedFile::fake()->create($filename, 50, 'application/pdf')], + ])->assertRedirect(); + + return Transfer::query()->where('title', $title)->firstOrFail(); + } + + public function test_owner_can_download_file(): void + { + Storage::fake('qr'); + $this->fakeTransferBillingApi(); + + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Test User', + 'email' => 'transfer+'.uniqid().'@example.com', + ]); + + $transfer = $this->createTransfer($user, 'Docs', 'brief.pdf'); + $file = $transfer->files()->first(); + + $this->actingAs($user) + ->get(route('transfer.files.download', $file)) + ->assertOk(); + } + + public function test_owner_can_delete_file(): void + { + Storage::fake('qr'); + $this->fakeTransferBillingApi(); + + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Test User', + 'email' => 'transfer+'.uniqid().'@example.com', + ]); + + $transfer = $this->createTransfer($user, 'Docs', 'brief.pdf'); + $file = $transfer->files()->first(); + + $this->actingAs($user) + ->delete(route('transfer.files.destroy', $file)) + ->assertRedirect(); + + $this->assertDatabaseMissing('transfer_files', ['id' => $file->id]); + } + + public function test_owner_can_move_file_to_another_transfer(): void + { + Storage::fake('qr'); + $this->fakeTransferBillingApi(); + + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Test User', + 'email' => 'transfer+'.uniqid().'@example.com', + ]); + + $source = $this->createTransfer($user, 'Source folder', 'one.pdf'); + $destination = $this->createTransfer($user, 'Destination folder', 'two.pdf'); + $file = $source->files()->first(); + + $this->actingAs($user) + ->post(route('transfer.files.move'), [ + 'destination' => $destination->id, + 'files' => [$file->id], + ]) + ->assertRedirect(route('transfer.files.index', ['folder' => $destination->id])); + + $this->assertSame($destination->id, $file->fresh()->transfer_id); + } + + public function test_files_index_supports_folder_filter(): void + { + Storage::fake('qr'); + $this->fakeTransferBillingApi(); + + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Test User', + 'email' => 'transfer+'.uniqid().'@example.com', + ]); + + $transfer = $this->createTransfer($user, 'BKC', 'cover.psd'); + + $this->actingAs($user) + ->get(route('transfer.files.index', ['folder' => $transfer->id])) + ->assertOk() + ->assertSee('cover.psd') + ->assertSee('BKC'); + } +} diff --git a/tests/Feature/TransferSearchTest.php b/tests/Feature/TransferSearchTest.php new file mode 100644 index 0000000..e2c1a15 --- /dev/null +++ b/tests/Feature/TransferSearchTest.php @@ -0,0 +1,72 @@ +fakeTransferBillingApi(); + + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Test User', + 'email' => 'transfer+'.uniqid().'@example.com', + ]); + + $this->actingAs($user)->post(route('transfer.transfers.store'), [ + 'title' => 'Marketing assets', + 'files' => [UploadedFile::fake()->create('logo.png', 50, 'image/png')], + ])->assertRedirect(); + + $this->actingAs($user)->post(route('transfer.transfers.store'), [ + 'title' => 'Finance reports', + 'files' => [UploadedFile::fake()->create('report.pdf', 50, 'application/pdf')], + ])->assertRedirect(); + + $this->actingAs($user) + ->get(route('transfer.transfers.index', ['q' => 'Marketing'])) + ->assertOk() + ->assertSee('Marketing assets') + ->assertDontSee('Finance reports'); + } + + public function test_global_search_returns_transfers_and_files(): void + { + Storage::fake('qr'); + $this->fakeTransferBillingApi(); + + $user = User::create([ + 'public_id' => (string) Str::uuid(), + 'name' => 'Test User', + 'email' => 'transfer+'.uniqid().'@example.com', + ]); + + $this->actingAs($user)->post(route('transfer.transfers.store'), [ + 'title' => 'Brand kit', + 'files' => [UploadedFile::fake()->create('cover.psd', 50, 'application/octet-stream')], + ])->assertRedirect(); + + $response = $this->actingAs($user) + ->getJson(route('transfer.search', ['q' => 'cover'])) + ->assertOk(); + + $results = $response->json('results'); + $this->assertNotEmpty($results); + $this->assertTrue(collect($results)->contains(fn ($item) => $item['type'] === 'file')); + } +}
TitleStatus Files Downloads Expires

{{ $transfer->title }}

{{ $transfer->created_at->format('M j, Y') }}

+ @if($transfer->recipient_email) +

To {{ $transfer->recipient_email }}

+ @endif +
+ + {{ $transfer->billingStatusLabel() }} + {{ $transfer->files->count() }} {{ number_format($transfer->downloads_total) }}