Add transfer search and OneDrive-style Files storage UI.
Deploy Ladill Transfer / deploy (push) Successful in 29s
Deploy Ladill Transfer / deploy (push) Successful in 29s
Transfers now lists all shares with filters and pagination; Files becomes a selectable cloud view with download, share, delete, move, and open-in-email actions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<array{type: string, title: string, subtitle: string, url: string}> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int|string>|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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TransferFile> $files */
|
||||
public function deleteFiles(array $files): void
|
||||
{
|
||||
foreach ($files as $file) {
|
||||
$this->deleteFile($file);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<TransferFile> $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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user