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)
|
||||
->get()
|
||||
->each(function (TransferFile $file) use (&$results) {
|
||||
$params = $file->transfer->is_folder
|
||||
? ['folder' => $file->transfer_id, 'q' => $file->original_name]
|
||||
: ['q' => $file->original_name];
|
||||
|
||||
$results[] = [
|
||||
'type' => 'file',
|
||||
'title' => $file->original_name,
|
||||
'subtitle' => $file->humanSize().' · '.$file->transfer->title,
|
||||
'url' => route('transfer.files.index', ['folder' => $file->transfer_id, 'q' => $file->original_name]),
|
||||
'subtitle' => $file->humanSize().($file->transfer->is_folder ? ' · '.$file->transfer->title : ''),
|
||||
'url' => route('transfer.files.index', $params),
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Transfer;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transfer;
|
||||
use App\Models\TransferFile;
|
||||
use App\Models\User;
|
||||
use App\Services\Transfer\FileStorageService;
|
||||
use App\Services\Transfer\TransferRecipientMailService;
|
||||
use App\Services\Transfer\TransferService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -21,6 +23,7 @@ class FilesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private TransferService $transfers,
|
||||
private FileStorageService $storage,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
@@ -35,6 +38,7 @@ class FilesController extends Controller
|
||||
if ($folderId) {
|
||||
$folderTransfer = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('is_folder', true)
|
||||
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||
->findOrFail($folderId);
|
||||
}
|
||||
@@ -42,8 +46,9 @@ class FilesController extends Controller
|
||||
$filesQuery = TransferFile::query()
|
||||
->whereHas('transfer', fn ($q) => $q
|
||||
->where('user_id', $account->id)
|
||||
->where('status', '!=', Transfer::STATUS_DELETED))
|
||||
->when($folderTransfer, fn ($q) => $q->where('transfer_id', $folderTransfer->id))
|
||||
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||
->when($folderTransfer, fn ($inner) => $inner->whereKey($folderTransfer->id),
|
||||
fn ($inner) => $inner->where('is_folder', false)))
|
||||
->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%'))
|
||||
->with(['transfer.qrCode'])
|
||||
->when($sort === 'oldest', fn ($q) => $q->oldest())
|
||||
@@ -56,8 +61,8 @@ class FilesController extends Controller
|
||||
|
||||
$folders = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('is_folder', true)
|
||||
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||
->whereHas('files')
|
||||
->withCount('files')
|
||||
->when($search !== '', fn ($q) => $q->where('title', 'like', '%'.$search.'%'))
|
||||
->orderBy('title')
|
||||
@@ -65,9 +70,12 @@ class FilesController extends Controller
|
||||
|
||||
$moveTargets = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->accessible()
|
||||
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||
->where(function ($query) {
|
||||
$query->where('is_folder', true)->orWhere('is_root_storage', true);
|
||||
})
|
||||
->orderBy('title')
|
||||
->get(['id', 'title']);
|
||||
->get(['id', 'title', 'is_root_storage']);
|
||||
|
||||
$storageBytes = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
@@ -184,14 +192,17 @@ class FilesController extends Controller
|
||||
|
||||
$destination = Transfer::query()
|
||||
->where('user_id', ladill_account()->id)
|
||||
->accessible()
|
||||
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||
->where(function ($query) {
|
||||
$query->where('is_folder', true)->orWhere('is_root_storage', true);
|
||||
})
|
||||
->findOrFail($data['destination']);
|
||||
|
||||
$files = $this->authorizedFiles($data['files']);
|
||||
$this->transfers->moveFiles($destination, $files->all());
|
||||
|
||||
return redirect()
|
||||
->route('transfer.files.index', ['folder' => $destination->id])
|
||||
->route('transfer.files.index', $destination->is_folder ? ['folder' => $destination->id] : [])
|
||||
->with('success', $files->count().' file'.($files->count() === 1 ? '' : 's').' moved to '.$destination->title.'.');
|
||||
}
|
||||
|
||||
@@ -273,6 +284,105 @@ class FilesController extends Controller
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
public function storeFolder(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:120',
|
||||
'folder' => 'nullable|integer|exists:transfers,id',
|
||||
]);
|
||||
|
||||
$account = ladill_account();
|
||||
$parent = null;
|
||||
if (! empty($data['folder'])) {
|
||||
$parent = Transfer::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('is_folder', true)
|
||||
->findOrFail($data['folder']);
|
||||
}
|
||||
|
||||
try {
|
||||
$folder = $this->storage->createFolder($account, $data['name'], $parent);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('transfer.files.index', ['folder' => $folder->id])
|
||||
->with('success', 'Folder created.');
|
||||
}
|
||||
|
||||
public function checkUpload(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'filenames' => 'required|array|min:1',
|
||||
'filenames.*' => 'required|string|max:255',
|
||||
'folder' => 'nullable|integer|exists:transfers,id',
|
||||
]);
|
||||
|
||||
$account = ladill_account();
|
||||
$folder = $this->resolveUploadFolder($account, $data['folder'] ?? null);
|
||||
|
||||
$conflicts = $this->storage->findConflicts(
|
||||
$account,
|
||||
$folder,
|
||||
$data['filenames'],
|
||||
);
|
||||
|
||||
return response()->json(['conflicts' => $conflicts]);
|
||||
}
|
||||
|
||||
public function storeUpload(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'files' => 'required|array|min:1',
|
||||
'files.*' => 'required|file',
|
||||
'folder' => 'nullable|integer|exists:transfers,id',
|
||||
'resolutions' => 'nullable|array',
|
||||
'resolutions.*' => 'in:replace,keep_both',
|
||||
]);
|
||||
|
||||
$account = ladill_account();
|
||||
$folder = $this->resolveUploadFolder($account, $data['folder'] ?? null);
|
||||
$files = array_values($request->file('files', []) ?? []);
|
||||
|
||||
try {
|
||||
$uploaded = $this->storage->uploadFiles(
|
||||
$account,
|
||||
$folder,
|
||||
$files,
|
||||
$data['resolutions'] ?? [],
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$count = count($uploaded);
|
||||
$message = $count.' file'.($count === 1 ? '' : 's').' uploaded.';
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $message, 'count' => $count]);
|
||||
}
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
private function resolveUploadFolder(User $user, ?int $folderId): ?Transfer
|
||||
{
|
||||
if ($folderId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Transfer::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('is_folder', true)
|
||||
->where('status', '!=', Transfer::STATUS_DELETED)
|
||||
->findOrFail($folderId);
|
||||
}
|
||||
|
||||
/** @param array<int|string>|null $ids */
|
||||
private function authorizedFiles(?array $ids): Collection
|
||||
{
|
||||
|
||||
@@ -34,11 +34,15 @@ class Transfer extends Model
|
||||
'grace_ends_at',
|
||||
'last_billed_at',
|
||||
'status',
|
||||
'is_folder',
|
||||
'is_root_storage',
|
||||
'downloads_total',
|
||||
'storage_bytes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_folder' => 'boolean',
|
||||
'is_root_storage' => 'boolean',
|
||||
'retention_days' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'paid_until' => 'datetime',
|
||||
@@ -157,6 +161,21 @@ class Transfer extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeStorageFolders(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_folder', true);
|
||||
}
|
||||
|
||||
public function isStorageContainer(): bool
|
||||
{
|
||||
return $this->is_folder || $this->is_root_storage;
|
||||
}
|
||||
|
||||
public function shouldPersistWhenEmpty(): bool
|
||||
{
|
||||
return $this->is_folder || $this->is_root_storage;
|
||||
}
|
||||
|
||||
public function wantsRecipientMilestone(string $milestone): bool
|
||||
{
|
||||
return in_array($milestone, (array) ($this->recipient_email_milestones ?? []), true);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
return $this->storeFileWithName(
|
||||
$user,
|
||||
$transfer,
|
||||
$file,
|
||||
$file->getClientOriginalName() ?: ('file.'.($file->getClientOriginalExtension() ?: 'bin')),
|
||||
);
|
||||
}
|
||||
|
||||
public function addStorageFile(User $user, Transfer $transfer, UploadedFile $file, string $displayName): TransferFile
|
||||
{
|
||||
$stored = $this->storeFileWithName($user, $transfer, $file, $displayName);
|
||||
$transfer->increment('storage_bytes', $stored->size_bytes);
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
private function storeFileWithName(User $user, Transfer $transfer, UploadedFile $file, string $displayName): TransferFile
|
||||
{
|
||||
$uuid = Str::uuid()->toString();
|
||||
$ext = $file->getClientOriginalExtension() ?: 'bin';
|
||||
@@ -131,7 +149,7 @@ class TransferService
|
||||
|
||||
return TransferFile::create([
|
||||
'transfer_id' => $transfer->id,
|
||||
'original_name' => $file->getClientOriginalName() ?: 'file.'.$ext,
|
||||
'original_name' => $displayName,
|
||||
'disk' => 'qr',
|
||||
'path' => $path,
|
||||
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
|
||||
@@ -208,7 +226,7 @@ class TransferService
|
||||
|
||||
$transfer->decrement('storage_bytes', $size);
|
||||
|
||||
if ($transfer->files()->count() === 0) {
|
||||
if ($transfer->files()->count() === 0 && ! $transfer->shouldPersistWhenEmpty()) {
|
||||
$this->delete($transfer->fresh(['files', 'qrCode']));
|
||||
}
|
||||
});
|
||||
@@ -248,7 +266,7 @@ class TransferService
|
||||
foreach ($sourceAdjustments as $sourceId => $bytes) {
|
||||
Transfer::query()->whereKey($sourceId)->decrement('storage_bytes', $bytes);
|
||||
$source = Transfer::query()->with('files')->find($sourceId);
|
||||
if ($source && $source->files->isEmpty() && $source->status !== Transfer::STATUS_DELETED) {
|
||||
if ($source && $source->files->isEmpty() && $source->status !== Transfer::STATUS_DELETED && ! $source->shouldPersistWhenEmpty()) {
|
||||
$this->delete($source->fresh(['files', 'qrCode']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || [],
|
||||
routes: config.routes || {},
|
||||
csrf: config.csrf || '',
|
||||
currentFolderId: config.currentFolderId || null,
|
||||
moveModalOpen: false,
|
||||
toast: '',
|
||||
_toastTimer: null,
|
||||
@@ -636,6 +637,129 @@ Alpine.data('filesManager', (config = {}) => ({
|
||||
this.selected.forEach((id) => params.append('files[]', id));
|
||||
window.location.href = `${this.routes.openInEmail}?${params.toString()}`;
|
||||
},
|
||||
|
||||
createMenuOpen: false,
|
||||
folderModalOpen: false,
|
||||
newFolderName: '',
|
||||
uploading: false,
|
||||
duplicateModalOpen: false,
|
||||
duplicateFile: null,
|
||||
pendingUploads: null,
|
||||
pendingResolutions: {},
|
||||
duplicateQueue: [],
|
||||
|
||||
triggerFileUpload() {
|
||||
this.createMenuOpen = false;
|
||||
this.$refs.fileUploadInput?.click();
|
||||
},
|
||||
|
||||
triggerFolderUpload() {
|
||||
this.createMenuOpen = false;
|
||||
this.$refs.folderUploadInput?.click();
|
||||
},
|
||||
|
||||
openFolderModal() {
|
||||
this.createMenuOpen = false;
|
||||
this.folderModalOpen = true;
|
||||
this.newFolderName = '';
|
||||
this.$nextTick(() => this.$refs.folderNameInput?.focus());
|
||||
},
|
||||
|
||||
async handleUploadPick(event) {
|
||||
const picked = Array.from(event.target.files || []);
|
||||
event.target.value = '';
|
||||
if (picked.length === 0) return;
|
||||
await this.processUploadQueue(picked);
|
||||
},
|
||||
|
||||
async processUploadQueue(files) {
|
||||
this.uploading = true;
|
||||
try {
|
||||
const checkRes = await fetch(this.routes.uploadCheck, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filenames: files.map((f) => f.name),
|
||||
folder: this.currentFolderId || null,
|
||||
}),
|
||||
});
|
||||
const checkData = await checkRes.json();
|
||||
this.pendingUploads = files;
|
||||
this.pendingResolutions = {};
|
||||
this.duplicateQueue = checkData.conflicts || [];
|
||||
|
||||
if (this.duplicateQueue.length > 0) {
|
||||
this.showNextDuplicate();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.submitUpload(files, {});
|
||||
} catch (e) {
|
||||
this.showToast('Upload failed. Please try again.');
|
||||
} finally {
|
||||
if (!this.duplicateModalOpen) {
|
||||
this.uploading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
showNextDuplicate() {
|
||||
if (this.duplicateQueue.length === 0) {
|
||||
this.duplicateModalOpen = false;
|
||||
this.submitUpload(this.pendingUploads, this.pendingResolutions).finally(() => {
|
||||
this.uploading = false;
|
||||
this.pendingUploads = null;
|
||||
this.pendingResolutions = {};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.duplicateFile = this.duplicateQueue.shift();
|
||||
this.duplicateModalOpen = true;
|
||||
},
|
||||
|
||||
resolveDuplicate(action) {
|
||||
if (!this.duplicateFile) return;
|
||||
this.pendingResolutions[this.duplicateFile.filename] = action;
|
||||
this.duplicateModalOpen = false;
|
||||
this.duplicateFile = null;
|
||||
this.showNextDuplicate();
|
||||
},
|
||||
|
||||
async submitUpload(files, resolutions) {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append('files[]', file));
|
||||
if (this.currentFolderId) {
|
||||
formData.append('folder', this.currentFolderId);
|
||||
}
|
||||
Object.entries(resolutions).forEach(([name, action]) => {
|
||||
formData.append(`resolutions[${name}]`, action);
|
||||
});
|
||||
|
||||
const res = await fetch(this.routes.upload, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
this.showToast(data.message || 'Upload failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.showToast(data.message || 'Upload complete.');
|
||||
window.location.reload();
|
||||
},
|
||||
}));
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
@@ -1,43 +1,20 @@
|
||||
<x-user-layout>
|
||||
<x-slot name="title">Files</x-slot>
|
||||
@php
|
||||
$iconBase = asset('images/ladill-icons');
|
||||
$fmtBytes = function (int $bytes) {
|
||||
if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2).' GB';
|
||||
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB';
|
||||
return number_format($bytes / 1024, 0).' KB';
|
||||
};
|
||||
$fileIcon = function (\App\Models\TransferFile $file): string {
|
||||
$mime = (string) $file->mime_type;
|
||||
$ext = strtolower(pathinfo($file->original_name, PATHINFO_EXTENSION));
|
||||
if (str_starts_with($mime, 'image/') || in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], true)) {
|
||||
return 'image';
|
||||
}
|
||||
if (str_starts_with($mime, 'video/') || in_array($ext, ['mp4', 'mov', 'avi', 'mkv'], true)) {
|
||||
return 'video';
|
||||
}
|
||||
if (str_starts_with($mime, 'audio/') || in_array($ext, ['mp3', 'wav', 'aac'], true)) {
|
||||
return 'audio';
|
||||
}
|
||||
if (in_array($ext, ['zip', 'rar', '7z', 'tar', 'gz'], true)) {
|
||||
return 'archive';
|
||||
}
|
||||
if (in_array($ext, ['pdf', 'doc', 'docx', 'txt', 'rtf'], true)) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
return 'file';
|
||||
};
|
||||
$fileRows = $files->map(fn ($file) => [
|
||||
'id' => $file->id,
|
||||
'name' => $file->original_name,
|
||||
'size' => $file->humanSize(),
|
||||
'downloads' => $file->downloads_total,
|
||||
'modified' => $file->updated_at?->format('M j, Y') ?? '—',
|
||||
'transferId' => $file->transfer_id,
|
||||
'transferTitle' => $file->transfer->title,
|
||||
'shareUrl' => $file->transfer->qrCode?->publicUrl() ?? '',
|
||||
'downloadUrl' => route('transfer.files.download', $file),
|
||||
'icon' => $fileIcon($file),
|
||||
'shared' => $file->transfer->recipient_email !== null || $file->downloads_total > 0,
|
||||
])->values();
|
||||
@endphp
|
||||
@@ -47,15 +24,21 @@
|
||||
'fileIds' => $fileRows->pluck('id')->all(),
|
||||
'files' => $fileRows->all(),
|
||||
'csrf' => csrf_token(),
|
||||
'currentFolderId' => $folderTransfer?->id,
|
||||
'routes' => [
|
||||
'bulkDownload' => route('transfer.files.bulk-download'),
|
||||
'bulkDelete' => route('transfer.files.bulk-delete'),
|
||||
'move' => route('transfer.files.move'),
|
||||
'share' => route('transfer.files.share'),
|
||||
'openInEmail' => route('transfer.files.open-in-email'),
|
||||
'reshareEmail' => route('transfer.files.reshare-email'),
|
||||
'uploadCheck' => route('transfer.files.upload.check'),
|
||||
'upload' => route('transfer.files.upload'),
|
||||
'createFolder' => route('transfer.files.folders.store'),
|
||||
],
|
||||
'moveTargets' => $moveTargets->map(fn ($t) => ['id' => $t->id, 'title' => $t->title])->values()->all(),
|
||||
'moveTargets' => $moveTargets->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'title' => $t->is_root_storage ? 'My files (root)' : $t->title,
|
||||
])->values()->all(),
|
||||
]))">
|
||||
|
||||
{{-- Header --}}
|
||||
@@ -64,18 +47,51 @@
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{{-- Breadcrumbs --}}
|
||||
<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)
|
||||
<span>/</span>
|
||||
<span class="font-medium text-slate-900">{{ $folderTransfer->title }}</span>
|
||||
@endif
|
||||
</nav>
|
||||
|
||||
{{-- Action toolbar --}}
|
||||
{{-- Action toolbar + file list --}}
|
||||
<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">
|
||||
<template x-if="selected.length === 0">
|
||||
@@ -119,31 +135,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Folders row (when at root) --}}
|
||||
@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())
|
||||
@if($files->isEmpty() && ($folderTransfer || $folders->isEmpty() || $search !== ''))
|
||||
<div class="px-6 py-12 text-center text-sm text-slate-500">
|
||||
@if($search !== '')
|
||||
No files match your search.
|
||||
@elseif($folderTransfer)
|
||||
This folder is empty.
|
||||
This folder is empty. Upload files or move items here.
|
||||
@else
|
||||
No stored files yet.
|
||||
No files yet. Use Create or upload to add files.
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@@ -157,14 +156,28 @@
|
||||
<th class="hidden px-4 py-3 sm:table-cell">Modified</th>
|
||||
<th class="px-4 py-3">Size</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>
|
||||
</thead>
|
||||
<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)
|
||||
@php $icon = $fileIcon($file); @endphp
|
||||
<tr class="transition"
|
||||
:class="selected.includes({{ $file->id }}) ? 'bg-indigo-50/80' : 'hover:bg-slate-50/80'">
|
||||
<td class="px-4 py-3">
|
||||
@@ -172,66 +185,89 @@
|
||||
</td>
|
||||
<td class="px-4 py-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">
|
||||
<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">
|
||||
<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 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="hidden px-4 py-3 md:table-cell">
|
||||
@if($file->transfer->recipient_email || $file->downloads_total > 0)
|
||||
<span class="inline-flex items-center gap-1 text-xs text-slate-600">
|
||||
<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>
|
||||
<span class="inline-flex items-center gap-1 text-xs text-slate-600">Shared</span>
|
||||
@else
|
||||
<span class="text-xs text-slate-400">Private</span>
|
||||
@endif
|
||||
</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>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@if($files->isNotEmpty())
|
||||
<div class="flex items-center justify-between border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
|
||||
<span>{{ $files->total() }} file{{ $files->total() === 1 ? '' : 's' }}</span>
|
||||
<span>Showing {{ $files->firstItem() }}–{{ $files->lastItem() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($files->hasPages())
|
||||
<div>{{ $files->links() }}</div>
|
||||
@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 --}}
|
||||
<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">
|
||||
<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">
|
||||
@csrf
|
||||
<template x-for="id in selected" :key="id">
|
||||
<input type="hidden" name="files[]" :value="id">
|
||||
</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">
|
||||
<option value="">Choose a transfer folder…</option>
|
||||
<option value="">Choose a folder…</option>
|
||||
@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
|
||||
</select>
|
||||
<div class="flex justify-end gap-2">
|
||||
@@ -242,6 +278,11 @@
|
||||
</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 --}}
|
||||
<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>
|
||||
|
||||
@@ -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('/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::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');
|
||||
|
||||
@@ -16,7 +16,16 @@ class TransferFilesTest extends TestCase
|
||||
use FakesTransferBilling;
|
||||
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'), [
|
||||
'title' => $title,
|
||||
@@ -26,18 +35,22 @@ class TransferFilesTest extends TestCase
|
||||
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
|
||||
{
|
||||
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');
|
||||
$user = $this->createUser();
|
||||
$transfer = $this->createShareTransfer($user, 'Docs', 'brief.pdf');
|
||||
$file = $transfer->files()->first();
|
||||
|
||||
$this->actingAs($user)
|
||||
@@ -50,13 +63,8 @@ class TransferFilesTest extends TestCase
|
||||
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');
|
||||
$user = $this->createUser();
|
||||
$transfer = $this->createShareTransfer($user, 'Docs', 'brief.pdf');
|
||||
$file = $transfer->files()->first();
|
||||
|
||||
$this->actingAs($user)
|
||||
@@ -66,48 +74,96 @@ class TransferFilesTest extends TestCase
|
||||
$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');
|
||||
$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();
|
||||
$user = $this->createUser();
|
||||
$this->createShareTransfer($user, 'Loose file', 'one.pdf');
|
||||
$file = Transfer::query()->where('title', 'Loose file')->first()->files()->first();
|
||||
$folder = $this->createFolder($user, 'Destination folder');
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('transfer.files.move'), [
|
||||
'destination' => $destination->id,
|
||||
'destination' => $folder->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');
|
||||
$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');
|
||||
$user = $this->createUser();
|
||||
$folder = $this->createFolder($user, 'BKC');
|
||||
|
||||
$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()
|
||||
->assertSee('cover.psd')
|
||||
->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