Add Files page upload flow with manual folders and duplicate handling.
Deploy Ladill Transfer / deploy (push) Successful in 44s
Deploy Ladill Transfer / deploy (push) Successful in 44s
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -63,11 +63,15 @@ class SearchController extends Controller
|
|||||||
->limit(8)
|
->limit(8)
|
||||||
->get()
|
->get()
|
||||||
->each(function (TransferFile $file) use (&$results) {
|
->each(function (TransferFile $file) use (&$results) {
|
||||||
|
$params = $file->transfer->is_folder
|
||||||
|
? ['folder' => $file->transfer_id, 'q' => $file->original_name]
|
||||||
|
: ['q' => $file->original_name];
|
||||||
|
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'type' => 'file',
|
'type' => 'file',
|
||||||
'title' => $file->original_name,
|
'title' => $file->original_name,
|
||||||
'subtitle' => $file->humanSize().' · '.$file->transfer->title,
|
'subtitle' => $file->humanSize().($file->transfer->is_folder ? ' · '.$file->transfer->title : ''),
|
||||||
'url' => route('transfer.files.index', ['folder' => $file->transfer_id, 'q' => $file->original_name]),
|
'url' => route('transfer.files.index', $params),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Transfer;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Transfer;
|
use App\Models\Transfer;
|
||||||
use App\Models\TransferFile;
|
use App\Models\TransferFile;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Transfer\FileStorageService;
|
||||||
use App\Services\Transfer\TransferRecipientMailService;
|
use App\Services\Transfer\TransferRecipientMailService;
|
||||||
use App\Services\Transfer\TransferService;
|
use App\Services\Transfer\TransferService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -21,6 +23,7 @@ class FilesController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private TransferService $transfers,
|
private TransferService $transfers,
|
||||||
|
private FileStorageService $storage,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): View
|
public function index(Request $request): View
|
||||||
@@ -35,6 +38,7 @@ class FilesController extends Controller
|
|||||||
if ($folderId) {
|
if ($folderId) {
|
||||||
$folderTransfer = Transfer::query()
|
$folderTransfer = Transfer::query()
|
||||||
->where('user_id', $account->id)
|
->where('user_id', $account->id)
|
||||||
|
->where('is_folder', true)
|
||||||
->where('status', '!=', Transfer::STATUS_DELETED)
|
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||||
->findOrFail($folderId);
|
->findOrFail($folderId);
|
||||||
}
|
}
|
||||||
@@ -42,8 +46,9 @@ class FilesController extends Controller
|
|||||||
$filesQuery = TransferFile::query()
|
$filesQuery = TransferFile::query()
|
||||||
->whereHas('transfer', fn ($q) => $q
|
->whereHas('transfer', fn ($q) => $q
|
||||||
->where('user_id', $account->id)
|
->where('user_id', $account->id)
|
||||||
->where('status', '!=', Transfer::STATUS_DELETED))
|
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||||
->when($folderTransfer, fn ($q) => $q->where('transfer_id', $folderTransfer->id))
|
->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.'%'))
|
->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%'))
|
||||||
->with(['transfer.qrCode'])
|
->with(['transfer.qrCode'])
|
||||||
->when($sort === 'oldest', fn ($q) => $q->oldest())
|
->when($sort === 'oldest', fn ($q) => $q->oldest())
|
||||||
@@ -56,8 +61,8 @@ class FilesController extends Controller
|
|||||||
|
|
||||||
$folders = Transfer::query()
|
$folders = Transfer::query()
|
||||||
->where('user_id', $account->id)
|
->where('user_id', $account->id)
|
||||||
|
->where('is_folder', true)
|
||||||
->where('status', '!=', Transfer::STATUS_DELETED)
|
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||||
->whereHas('files')
|
|
||||||
->withCount('files')
|
->withCount('files')
|
||||||
->when($search !== '', fn ($q) => $q->where('title', 'like', '%'.$search.'%'))
|
->when($search !== '', fn ($q) => $q->where('title', 'like', '%'.$search.'%'))
|
||||||
->orderBy('title')
|
->orderBy('title')
|
||||||
@@ -65,9 +70,12 @@ class FilesController extends Controller
|
|||||||
|
|
||||||
$moveTargets = Transfer::query()
|
$moveTargets = Transfer::query()
|
||||||
->where('user_id', $account->id)
|
->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')
|
->orderBy('title')
|
||||||
->get(['id', 'title']);
|
->get(['id', 'title', 'is_root_storage']);
|
||||||
|
|
||||||
$storageBytes = Transfer::query()
|
$storageBytes = Transfer::query()
|
||||||
->where('user_id', $account->id)
|
->where('user_id', $account->id)
|
||||||
@@ -184,14 +192,17 @@ class FilesController extends Controller
|
|||||||
|
|
||||||
$destination = Transfer::query()
|
$destination = Transfer::query()
|
||||||
->where('user_id', ladill_account()->id)
|
->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']);
|
->findOrFail($data['destination']);
|
||||||
|
|
||||||
$files = $this->authorizedFiles($data['files']);
|
$files = $this->authorizedFiles($data['files']);
|
||||||
$this->transfers->moveFiles($destination, $files->all());
|
$this->transfers->moveFiles($destination, $files->all());
|
||||||
|
|
||||||
return redirect()
|
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.'.');
|
->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);
|
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<int|string>|null $ids */
|
/** @param array<int|string>|null $ids */
|
||||||
private function authorizedFiles(?array $ids): Collection
|
private function authorizedFiles(?array $ids): Collection
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ class Transfer extends Model
|
|||||||
'grace_ends_at',
|
'grace_ends_at',
|
||||||
'last_billed_at',
|
'last_billed_at',
|
||||||
'status',
|
'status',
|
||||||
|
'is_folder',
|
||||||
|
'is_root_storage',
|
||||||
'downloads_total',
|
'downloads_total',
|
||||||
'storage_bytes',
|
'storage_bytes',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'is_folder' => 'boolean',
|
||||||
|
'is_root_storage' => 'boolean',
|
||||||
'retention_days' => 'integer',
|
'retention_days' => 'integer',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'paid_until' => '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
|
public function wantsRecipientMilestone(string $milestone): bool
|
||||||
{
|
{
|
||||||
return in_array($milestone, (array) ($this->recipient_email_milestones ?? []), true);
|
return in_array($milestone, (array) ($this->recipient_email_milestones ?? []), true);
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Transfer;
|
||||||
|
|
||||||
|
use App\Models\Transfer;
|
||||||
|
use App\Models\TransferFile;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/** User-managed file storage: folders, root bucket, uploads, and duplicate handling. */
|
||||||
|
class FileStorageService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TransferService $transfers,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function rootStorage(User $user): Transfer
|
||||||
|
{
|
||||||
|
$existing = Transfer::query()
|
||||||
|
->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<string> $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<UploadedFile> $files
|
||||||
|
* @param array<string, 'replace'|'keep_both'> $resolutions
|
||||||
|
* @return list<TransferFile>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,24 @@ class TransferService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function storeFile(User $user, Transfer $transfer, UploadedFile $file): TransferFile
|
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();
|
$uuid = Str::uuid()->toString();
|
||||||
$ext = $file->getClientOriginalExtension() ?: 'bin';
|
$ext = $file->getClientOriginalExtension() ?: 'bin';
|
||||||
@@ -131,7 +149,7 @@ class TransferService
|
|||||||
|
|
||||||
return TransferFile::create([
|
return TransferFile::create([
|
||||||
'transfer_id' => $transfer->id,
|
'transfer_id' => $transfer->id,
|
||||||
'original_name' => $file->getClientOriginalName() ?: 'file.'.$ext,
|
'original_name' => $displayName,
|
||||||
'disk' => 'qr',
|
'disk' => 'qr',
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
|
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
|
||||||
@@ -208,7 +226,7 @@ class TransferService
|
|||||||
|
|
||||||
$transfer->decrement('storage_bytes', $size);
|
$transfer->decrement('storage_bytes', $size);
|
||||||
|
|
||||||
if ($transfer->files()->count() === 0) {
|
if ($transfer->files()->count() === 0 && ! $transfer->shouldPersistWhenEmpty()) {
|
||||||
$this->delete($transfer->fresh(['files', 'qrCode']));
|
$this->delete($transfer->fresh(['files', 'qrCode']));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -248,7 +266,7 @@ class TransferService
|
|||||||
foreach ($sourceAdjustments as $sourceId => $bytes) {
|
foreach ($sourceAdjustments as $sourceId => $bytes) {
|
||||||
Transfer::query()->whereKey($sourceId)->decrement('storage_bytes', $bytes);
|
Transfer::query()->whereKey($sourceId)->decrement('storage_bytes', $bytes);
|
||||||
$source = Transfer::query()->with('files')->find($sourceId);
|
$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']));
|
$this->delete($source->fresh(['files', 'qrCode']));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('transfers', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1545_8888)">
|
||||||
|
<path d="M8.5 13.5H1.5C1.23478 13.5 0.98043 13.3946 0.792893 13.2071C0.605357 13.0196 0.5 12.7652 0.5 12.5V1.5C0.5 1.23478 0.605357 0.98043 0.792893 0.792893C0.98043 0.605357 1.23478 0.5 1.5 0.5H12.5C12.7652 0.5 13.0196 0.605357 13.2071 0.792893C13.3946 0.98043 13.5 1.23478 13.5 1.5V8.5L8.5 13.5Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.5 9V13.5L13.5 8.5H9C8.86739 8.5 8.74021 8.55268 8.64645 8.64645C8.55268 8.74021 8.5 8.86739 8.5 9Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1545_8888">
|
||||||
|
<rect width="14" height="14" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 792 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_1545_8905)">
|
||||||
|
<path d="M13.5 6C13.5 5.60218 13.342 5.22064 13.0607 4.93934C12.7794 4.65804 12.3978 4.5 12 4.5H7L5.56 1.5H2C1.60218 1.5 1.22064 1.65804 0.93934 1.93934C0.658035 2.22064 0.5 2.60218 0.5 3V11C0.5 11.3978 0.658035 11.7794 0.93934 12.0607C1.22064 12.342 1.60218 12.5 2 12.5H12C12.3978 12.5 12.7794 12.342 13.0607 12.0607C13.342 11.7794 13.5 11.3978 13.5 11V6Z" stroke="#000001" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1545_8905">
|
||||||
|
<rect width="14" height="14" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 673 B |
@@ -515,6 +515,7 @@ Alpine.data('filesManager', (config = {}) => ({
|
|||||||
files: config.files || [],
|
files: config.files || [],
|
||||||
routes: config.routes || {},
|
routes: config.routes || {},
|
||||||
csrf: config.csrf || '',
|
csrf: config.csrf || '',
|
||||||
|
currentFolderId: config.currentFolderId || null,
|
||||||
moveModalOpen: false,
|
moveModalOpen: false,
|
||||||
toast: '',
|
toast: '',
|
||||||
_toastTimer: null,
|
_toastTimer: null,
|
||||||
@@ -636,6 +637,129 @@ Alpine.data('filesManager', (config = {}) => ({
|
|||||||
this.selected.forEach((id) => params.append('files[]', id));
|
this.selected.forEach((id) => params.append('files[]', id));
|
||||||
window.location.href = `${this.routes.openInEmail}?${params.toString()}`;
|
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;
|
window.Alpine = Alpine;
|
||||||
|
|||||||
@@ -1,43 +1,20 @@
|
|||||||
<x-user-layout>
|
<x-user-layout>
|
||||||
<x-slot name="title">Files</x-slot>
|
<x-slot name="title">Files</x-slot>
|
||||||
@php
|
@php
|
||||||
|
$iconBase = asset('images/ladill-icons');
|
||||||
$fmtBytes = function (int $bytes) {
|
$fmtBytes = function (int $bytes) {
|
||||||
if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2).' GB';
|
if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2).' GB';
|
||||||
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB';
|
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB';
|
||||||
return number_format($bytes / 1024, 0).' KB';
|
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) => [
|
$fileRows = $files->map(fn ($file) => [
|
||||||
'id' => $file->id,
|
'id' => $file->id,
|
||||||
'name' => $file->original_name,
|
'name' => $file->original_name,
|
||||||
'size' => $file->humanSize(),
|
'size' => $file->humanSize(),
|
||||||
'downloads' => $file->downloads_total,
|
'downloads' => $file->downloads_total,
|
||||||
'modified' => $file->updated_at?->format('M j, Y') ?? '—',
|
'modified' => $file->updated_at?->format('M j, Y') ?? '—',
|
||||||
'transferId' => $file->transfer_id,
|
|
||||||
'transferTitle' => $file->transfer->title,
|
|
||||||
'shareUrl' => $file->transfer->qrCode?->publicUrl() ?? '',
|
'shareUrl' => $file->transfer->qrCode?->publicUrl() ?? '',
|
||||||
'downloadUrl' => route('transfer.files.download', $file),
|
'downloadUrl' => route('transfer.files.download', $file),
|
||||||
'icon' => $fileIcon($file),
|
|
||||||
'shared' => $file->transfer->recipient_email !== null || $file->downloads_total > 0,
|
'shared' => $file->transfer->recipient_email !== null || $file->downloads_total > 0,
|
||||||
])->values();
|
])->values();
|
||||||
@endphp
|
@endphp
|
||||||
@@ -47,15 +24,21 @@
|
|||||||
'fileIds' => $fileRows->pluck('id')->all(),
|
'fileIds' => $fileRows->pluck('id')->all(),
|
||||||
'files' => $fileRows->all(),
|
'files' => $fileRows->all(),
|
||||||
'csrf' => csrf_token(),
|
'csrf' => csrf_token(),
|
||||||
|
'currentFolderId' => $folderTransfer?->id,
|
||||||
'routes' => [
|
'routes' => [
|
||||||
'bulkDownload' => route('transfer.files.bulk-download'),
|
'bulkDownload' => route('transfer.files.bulk-download'),
|
||||||
'bulkDelete' => route('transfer.files.bulk-delete'),
|
'bulkDelete' => route('transfer.files.bulk-delete'),
|
||||||
'move' => route('transfer.files.move'),
|
'move' => route('transfer.files.move'),
|
||||||
'share' => route('transfer.files.share'),
|
'share' => route('transfer.files.share'),
|
||||||
'openInEmail' => route('transfer.files.open-in-email'),
|
'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 --}}
|
{{-- Header --}}
|
||||||
@@ -64,18 +47,51 @@
|
|||||||
<h1 class="text-xl font-semibold text-slate-900">Files</h1>
|
<h1 class="text-xl font-semibold text-slate-900">Files</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">{{ $fmtBytes($storageBytes) }} stored (~GHS {{ number_format($storageGb * $pricePerGb, 2) }}/month)</p>
|
<p class="mt-1 text-sm text-slate-500">{{ $fmtBytes($storageBytes) }} stored (~GHS {{ number_format($storageGb * $pricePerGb, 2) }}/month)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Create or upload --}}
|
||||||
|
<div class="relative" @click.outside="createMenuOpen = false">
|
||||||
|
<button type="button"
|
||||||
|
@click="createMenuOpen = !createMenuOpen"
|
||||||
|
class="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-indigo-600 to-violet-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:from-indigo-700 hover:to-violet-700">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||||
|
Create or upload
|
||||||
|
<svg class="h-4 w-4 opacity-80" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="createMenuOpen"
|
||||||
|
x-cloak
|
||||||
|
x-transition
|
||||||
|
class="absolute right-0 z-30 mt-2 w-56 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg">
|
||||||
|
<button type="button" @click="openFolderModal()" class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||||
|
<img src="{{ $iconBase }}/folder.svg" alt="" class="h-5 w-5">
|
||||||
|
<span>Folder</span>
|
||||||
|
</button>
|
||||||
|
<div class="my-1 border-t border-slate-100"></div>
|
||||||
|
<button type="button" @click="triggerFileUpload()" class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||||
|
<img src="{{ $iconBase }}/file.svg" alt="" class="h-5 w-5">
|
||||||
|
<span>Files upload</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="triggerFolderUpload()" class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||||
|
<span class="relative h-5 w-5">
|
||||||
|
<img src="{{ $iconBase }}/folder.svg" alt="" class="h-5 w-5">
|
||||||
|
</span>
|
||||||
|
<span>Folder upload</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" x-ref="fileUploadInput" multiple class="hidden" @change="handleUploadPick($event)">
|
||||||
|
<input type="file" x-ref="folderUploadInput" multiple webkitdirectory class="hidden" @change="handleUploadPick($event)">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Breadcrumbs --}}
|
{{-- Breadcrumbs --}}
|
||||||
<nav class="flex flex-wrap items-center gap-1 text-sm text-slate-500">
|
<nav class="flex flex-wrap items-center gap-1 text-sm text-slate-500">
|
||||||
<a href="{{ route('transfer.files.index', request()->except('folder')) }}" class="font-medium text-indigo-600 hover:text-indigo-800">My files</a>
|
<a href="{{ route('transfer.files.index') }}" class="font-medium text-indigo-600 hover:text-indigo-800">My files</a>
|
||||||
@if($folderTransfer)
|
@if($folderTransfer)
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span class="font-medium text-slate-900">{{ $folderTransfer->title }}</span>
|
<span class="font-medium text-slate-900">{{ $folderTransfer->title }}</span>
|
||||||
@endif
|
@endif
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{{-- Action toolbar --}}
|
{{-- Action toolbar + file list --}}
|
||||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-2.5">
|
<div class="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-2.5">
|
||||||
<template x-if="selected.length === 0">
|
<template x-if="selected.length === 0">
|
||||||
@@ -119,31 +135,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Folders row (when at root) --}}
|
@if($files->isEmpty() && ($folderTransfer || $folders->isEmpty() || $search !== ''))
|
||||||
@if(! $folderTransfer && $folders->isNotEmpty() && $search === '')
|
|
||||||
<div class="border-b border-slate-100 px-4 py-3">
|
|
||||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">Folders</p>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach($folders as $folder)
|
|
||||||
<a href="{{ route('transfer.files.index', ['folder' => $folder->id]) }}"
|
|
||||||
class="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 transition hover:border-indigo-200 hover:bg-indigo-50/60">
|
|
||||||
<svg class="h-5 w-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6Z"/></svg>
|
|
||||||
<span class="font-medium">{{ $folder->title }}</span>
|
|
||||||
<span class="text-xs text-slate-400">{{ $folder->files_count }}</span>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($files->isEmpty())
|
|
||||||
<div class="px-6 py-12 text-center text-sm text-slate-500">
|
<div class="px-6 py-12 text-center text-sm text-slate-500">
|
||||||
@if($search !== '')
|
@if($search !== '')
|
||||||
No files match your search.
|
No files match your search.
|
||||||
@elseif($folderTransfer)
|
@elseif($folderTransfer)
|
||||||
This folder is empty.
|
This folder is empty. Upload files or move items here.
|
||||||
@else
|
@else
|
||||||
No stored files yet.
|
No files yet. Use Create or upload to add files.
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@@ -157,14 +156,28 @@
|
|||||||
<th class="hidden px-4 py-3 sm:table-cell">Modified</th>
|
<th class="hidden px-4 py-3 sm:table-cell">Modified</th>
|
||||||
<th class="px-4 py-3">Size</th>
|
<th class="px-4 py-3">Size</th>
|
||||||
<th class="hidden px-4 py-3 md:table-cell">Sharing</th>
|
<th class="hidden px-4 py-3 md:table-cell">Sharing</th>
|
||||||
@unless($folderTransfer)
|
|
||||||
<th class="hidden px-4 py-3 lg:table-cell">Folder</th>
|
|
||||||
@endunless
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{{-- User-created folders (root only) --}}
|
||||||
|
@if(! $folderTransfer && $search === '')
|
||||||
|
@foreach($folders as $folder)
|
||||||
|
<tr class="hover:bg-slate-50/80">
|
||||||
|
<td class="px-4 py-3"></td>
|
||||||
|
<td class="px-4 py-3" colspan="4">
|
||||||
|
<a href="{{ route('transfer.files.index', ['folder' => $folder->id]) }}" class="flex items-center gap-3">
|
||||||
|
<img src="{{ $iconBase }}/folder.svg" alt="" class="h-8 w-8">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-slate-900">{{ $folder->title }}</p>
|
||||||
|
<p class="text-xs text-slate-500">{{ $folder->files_count }} item{{ $folder->files_count === 1 ? '' : 's' }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
|
||||||
@foreach($files as $file)
|
@foreach($files as $file)
|
||||||
@php $icon = $fileIcon($file); @endphp
|
|
||||||
<tr class="transition"
|
<tr class="transition"
|
||||||
:class="selected.includes({{ $file->id }}) ? 'bg-indigo-50/80' : 'hover:bg-slate-50/80'">
|
:class="selected.includes({{ $file->id }}) ? 'bg-indigo-50/80' : 'hover:bg-slate-50/80'">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -172,45 +185,34 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@include('transfer.files.partials.file-icon', ['icon' => $icon])
|
<img src="{{ $iconBase }}/file.svg" alt="" class="h-8 w-8 shrink-0">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate font-medium text-slate-900">{{ $file->original_name }}</p>
|
<p class="truncate font-medium text-slate-900">{{ $file->original_name }}</p>
|
||||||
<div class="mt-0.5 flex items-center gap-2 sm:hidden">
|
|
||||||
<span class="text-xs text-slate-500">{{ $file->humanSize() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-auto flex items-center gap-1 opacity-0 transition group-hover:opacity-100 sm:opacity-100" x-show="selected.includes({{ $file->id }})">
|
|
||||||
<a href="{{ route('transfer.files.download', $file) }}" class="rounded p-1 text-slate-500 hover:bg-white hover:text-indigo-600" title="Download">
|
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 7.5 12 3m0 0L7.5 7.5M12 3v13.5"/></svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<a href="{{ route('transfer.files.download', $file) }}" class="ml-auto rounded p-1 text-slate-400 hover:text-indigo-600" title="Download">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 7.5 12 3m0 0L7.5 7.5M12 3v13.5"/></svg>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden px-4 py-3 text-slate-600 sm:table-cell">{{ $file->updated_at?->format('M j, Y') ?? '—' }}</td>
|
<td class="hidden px-4 py-3 text-slate-600 sm:table-cell">{{ $file->updated_at?->format('M j, Y') ?? '—' }}</td>
|
||||||
<td class="px-4 py-3 text-slate-600">{{ $file->humanSize() }}</td>
|
<td class="px-4 py-3 text-slate-600">{{ $file->humanSize() }}</td>
|
||||||
<td class="hidden px-4 py-3 md:table-cell">
|
<td class="hidden px-4 py-3 md:table-cell">
|
||||||
@if($file->transfer->recipient_email || $file->downloads_total > 0)
|
@if($file->transfer->recipient_email || $file->downloads_total > 0)
|
||||||
<span class="inline-flex items-center gap-1 text-xs text-slate-600">
|
<span class="inline-flex items-center gap-1 text-xs text-slate-600">Shared</span>
|
||||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/></svg>
|
|
||||||
Shared
|
|
||||||
</span>
|
|
||||||
@else
|
@else
|
||||||
<span class="text-xs text-slate-400">Private</span>
|
<span class="text-xs text-slate-400">Private</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
@unless($folderTransfer)
|
|
||||||
<td class="hidden px-4 py-3 lg:table-cell">
|
|
||||||
<a href="{{ route('transfer.files.index', ['folder' => $file->transfer_id]) }}" class="text-indigo-600 hover:text-indigo-800">{{ $file->transfer->title }}</a>
|
|
||||||
</td>
|
|
||||||
@endunless
|
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="flex items-center justify-between border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
|
@if($files->isNotEmpty())
|
||||||
<span>{{ $files->total() }} file{{ $files->total() === 1 ? '' : 's' }}</span>
|
<div class="flex items-center justify-between border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
|
||||||
<span>Showing {{ $files->firstItem() }}–{{ $files->lastItem() }}</span>
|
<span>{{ $files->total() }} file{{ $files->total() === 1 ? '' : 's' }}</span>
|
||||||
</div>
|
<span>Showing {{ $files->firstItem() }}–{{ $files->lastItem() }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,20 +220,54 @@
|
|||||||
<div>{{ $files->links() }}</div>
|
<div>{{ $files->links() }}</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Create folder modal --}}
|
||||||
|
<div x-show="folderModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @keydown.escape.window="folderModalOpen = false">
|
||||||
|
<div @click.outside="folderModalOpen = false" class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900">New folder</h3>
|
||||||
|
<form method="post" action="{{ route('transfer.files.folders.store') }}" class="mt-4 space-y-4">
|
||||||
|
@csrf
|
||||||
|
@if($folderTransfer)
|
||||||
|
<input type="hidden" name="folder" value="{{ $folderTransfer->id }}">
|
||||||
|
@endif
|
||||||
|
<input x-ref="folderNameInput" type="text" name="name" x-model="newFolderName" required maxlength="120" placeholder="Folder name"
|
||||||
|
class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" @click="folderModalOpen = false" class="rounded-xl px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100">Cancel</button>
|
||||||
|
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Duplicate file modal --}}
|
||||||
|
<div x-show="duplicateModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-xl">
|
||||||
|
<p class="text-sm text-slate-700">
|
||||||
|
A file with this name already exists so we couldn't upload
|
||||||
|
<strong x-text="duplicateFile?.filename"></strong>.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Add it as a new version of the existing file, or keep them both.</p>
|
||||||
|
<div class="mt-5 flex gap-4">
|
||||||
|
<button type="button" @click="resolveDuplicate('replace')" class="text-sm font-semibold text-indigo-600 hover:text-indigo-800">Replace</button>
|
||||||
|
<button type="button" @click="resolveDuplicate('keep_both')" class="text-sm font-semibold text-indigo-600 hover:text-indigo-800">Keep both</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Move modal --}}
|
{{-- Move modal --}}
|
||||||
<div x-show="moveModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @keydown.escape.window="moveModalOpen = false">
|
<div x-show="moveModalOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @keydown.escape.window="moveModalOpen = false">
|
||||||
<div @click.outside="moveModalOpen = false" class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
<div @click.outside="moveModalOpen = false" class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||||
<h3 class="text-lg font-semibold text-slate-900">Move to folder</h3>
|
<h3 class="text-lg font-semibold text-slate-900">Move to folder</h3>
|
||||||
<p class="mt-1 text-sm text-slate-500">Group selected files into another transfer folder.</p>
|
<p class="mt-1 text-sm text-slate-500">Group selected files into a folder you created.</p>
|
||||||
<form method="post" action="{{ route('transfer.files.move') }}" class="mt-4 space-y-4">
|
<form method="post" action="{{ route('transfer.files.move') }}" class="mt-4 space-y-4">
|
||||||
@csrf
|
@csrf
|
||||||
<template x-for="id in selected" :key="id">
|
<template x-for="id in selected" :key="id">
|
||||||
<input type="hidden" name="files[]" :value="id">
|
<input type="hidden" name="files[]" :value="id">
|
||||||
</template>
|
</template>
|
||||||
<select name="destination" required class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
<select name="destination" required class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||||
<option value="">Choose a transfer folder…</option>
|
<option value="">Choose a folder…</option>
|
||||||
@foreach($moveTargets as $target)
|
@foreach($moveTargets as $target)
|
||||||
<option value="{{ $target->id }}">{{ $target->title }}</option>
|
<option value="{{ $target->id }}">{{ $target->is_root_storage ? 'My files (root)' : $target->title }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
@@ -242,6 +278,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Upload overlay --}}
|
||||||
|
<div x-show="uploading" x-cloak class="fixed inset-0 z-40 flex items-center justify-center bg-black/20">
|
||||||
|
<div class="rounded-xl bg-white px-5 py-3 text-sm font-medium text-slate-700 shadow-lg">Uploading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Toast --}}
|
{{-- Toast --}}
|
||||||
<div x-show="toast" x-cloak x-transition class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-xl bg-slate-900 px-4 py-2 text-sm text-white shadow-lg lg:bottom-8" x-text="toast"></div>
|
<div x-show="toast" x-cloak x-transition class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-xl bg-slate-900 px-4 py-2 text-sm text-white shadow-lg lg:bottom-8" x-text="toast"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ 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('/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', [FilesController::class, 'index'])->name('transfer.files.index');
|
||||||
|
Route::post('/files/folders', [FilesController::class, 'storeFolder'])->name('transfer.files.folders.store');
|
||||||
|
Route::post('/files/upload/check', [FilesController::class, 'checkUpload'])->name('transfer.files.upload.check');
|
||||||
|
Route::post('/files/upload', [FilesController::class, 'storeUpload'])->name('transfer.files.upload');
|
||||||
Route::get('/files/open-in-email', [FilesController::class, 'openInEmail'])->name('transfer.files.open-in-email');
|
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-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/bulk-delete', [FilesController::class, 'bulkDestroy'])->name('transfer.files.bulk-delete');
|
||||||
|
|||||||
@@ -16,7 +16,16 @@ class TransferFilesTest extends TestCase
|
|||||||
use FakesTransferBilling;
|
use FakesTransferBilling;
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
private function createTransfer(User $user, string $title, string $filename): Transfer
|
private function createUser(): User
|
||||||
|
{
|
||||||
|
return User::create([
|
||||||
|
'public_id' => (string) Str::uuid(),
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'transfer+'.uniqid().'@example.com',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createShareTransfer(User $user, string $title, string $filename): Transfer
|
||||||
{
|
{
|
||||||
$this->actingAs($user)->post(route('transfer.transfers.store'), [
|
$this->actingAs($user)->post(route('transfer.transfers.store'), [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
@@ -26,18 +35,22 @@ class TransferFilesTest extends TestCase
|
|||||||
return Transfer::query()->where('title', $title)->firstOrFail();
|
return Transfer::query()->where('title', $title)->firstOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createFolder(User $user, string $name): Transfer
|
||||||
|
{
|
||||||
|
$this->actingAs($user)->post(route('transfer.files.folders.store'), [
|
||||||
|
'name' => $name,
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
return Transfer::query()->where('title', $name)->where('is_folder', true)->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_owner_can_download_file(): void
|
public function test_owner_can_download_file(): void
|
||||||
{
|
{
|
||||||
Storage::fake('qr');
|
Storage::fake('qr');
|
||||||
$this->fakeTransferBillingApi();
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
$user = User::create([
|
$user = $this->createUser();
|
||||||
'public_id' => (string) Str::uuid(),
|
$transfer = $this->createShareTransfer($user, 'Docs', 'brief.pdf');
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'transfer+'.uniqid().'@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$transfer = $this->createTransfer($user, 'Docs', 'brief.pdf');
|
|
||||||
$file = $transfer->files()->first();
|
$file = $transfer->files()->first();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@@ -50,13 +63,8 @@ class TransferFilesTest extends TestCase
|
|||||||
Storage::fake('qr');
|
Storage::fake('qr');
|
||||||
$this->fakeTransferBillingApi();
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
$user = User::create([
|
$user = $this->createUser();
|
||||||
'public_id' => (string) Str::uuid(),
|
$transfer = $this->createShareTransfer($user, 'Docs', 'brief.pdf');
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'transfer+'.uniqid().'@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$transfer = $this->createTransfer($user, 'Docs', 'brief.pdf');
|
|
||||||
$file = $transfer->files()->first();
|
$file = $transfer->files()->first();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@@ -66,48 +74,96 @@ class TransferFilesTest extends TestCase
|
|||||||
$this->assertDatabaseMissing('transfer_files', ['id' => $file->id]);
|
$this->assertDatabaseMissing('transfer_files', ['id' => $file->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_owner_can_move_file_to_another_transfer(): void
|
public function test_owner_can_move_file_into_user_folder(): void
|
||||||
{
|
{
|
||||||
Storage::fake('qr');
|
Storage::fake('qr');
|
||||||
$this->fakeTransferBillingApi();
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
$user = User::create([
|
$user = $this->createUser();
|
||||||
'public_id' => (string) Str::uuid(),
|
$this->createShareTransfer($user, 'Loose file', 'one.pdf');
|
||||||
'name' => 'Test User',
|
$file = Transfer::query()->where('title', 'Loose file')->first()->files()->first();
|
||||||
'email' => 'transfer+'.uniqid().'@example.com',
|
$folder = $this->createFolder($user, 'Destination folder');
|
||||||
]);
|
|
||||||
|
|
||||||
$source = $this->createTransfer($user, 'Source folder', 'one.pdf');
|
|
||||||
$destination = $this->createTransfer($user, 'Destination folder', 'two.pdf');
|
|
||||||
$file = $source->files()->first();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->post(route('transfer.files.move'), [
|
->post(route('transfer.files.move'), [
|
||||||
'destination' => $destination->id,
|
'destination' => $folder->id,
|
||||||
'files' => [$file->id],
|
'files' => [$file->id],
|
||||||
])
|
])
|
||||||
->assertRedirect(route('transfer.files.index', ['folder' => $destination->id]));
|
->assertRedirect(route('transfer.files.index', ['folder' => $folder->id]));
|
||||||
|
|
||||||
$this->assertSame($destination->id, $file->fresh()->transfer_id);
|
$this->assertSame($folder->id, $file->fresh()->transfer_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_files_index_supports_folder_filter(): void
|
public function test_files_index_shows_user_folder(): void
|
||||||
{
|
{
|
||||||
Storage::fake('qr');
|
Storage::fake('qr');
|
||||||
$this->fakeTransferBillingApi();
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
$user = User::create([
|
$user = $this->createUser();
|
||||||
'public_id' => (string) Str::uuid(),
|
$folder = $this->createFolder($user, 'BKC');
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'transfer+'.uniqid().'@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$transfer = $this->createTransfer($user, 'BKC', 'cover.psd');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('transfer.files.index', ['folder' => $transfer->id]))
|
->get(route('transfer.files.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('BKC');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('transfer.files.index', ['folder' => $folder->id]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('cover.psd')
|
|
||||||
->assertSee('BKC');
|
->assertSee('BKC');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_upload_detects_duplicate_filenames(): void
|
||||||
|
{
|
||||||
|
Storage::fake('qr');
|
||||||
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
|
$user = $this->createUser();
|
||||||
|
$this->createShareTransfer($user, 'Docs', 'brief.pdf');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->postJson(route('transfer.files.upload.check'), [
|
||||||
|
'filenames' => ['brief.pdf', 'new.docx'],
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('conflicts.0.filename', 'brief.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_upload_can_replace_duplicate_file(): void
|
||||||
|
{
|
||||||
|
Storage::fake('qr');
|
||||||
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
|
$user = $this->createUser();
|
||||||
|
$this->createShareTransfer($user, 'Docs', 'brief.pdf');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('transfer.files.upload'), [
|
||||||
|
'files' => [UploadedFile::fake()->create('brief.pdf', 80, 'application/pdf')],
|
||||||
|
'resolutions' => ['brief.pdf' => 'replace'],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertSame(1, \App\Models\TransferFile::query()->count());
|
||||||
|
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief.pdf']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_upload_keep_both_renames_duplicate(): void
|
||||||
|
{
|
||||||
|
Storage::fake('qr');
|
||||||
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
|
$user = $this->createUser();
|
||||||
|
$this->createShareTransfer($user, 'Docs', 'brief.pdf');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('transfer.files.upload'), [
|
||||||
|
'files' => [UploadedFile::fake()->create('brief.pdf', 80, 'application/pdf')],
|
||||||
|
'resolutions' => ['brief.pdf' => 'keep_both'],
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief.pdf']);
|
||||||
|
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief (1).pdf']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user