Separate share transfers from Files storage and add folder upload to New transfer.
Deploy Ladill Transfer / deploy (push) Successful in 28s

Transfers lists only intentional shares while Files shows all storage, and New transfer accepts folder picks with flattened file uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 22:31:45 +00:00
co-authored by Cursor
parent be3b719437
commit 1a95915e21
9 changed files with 181 additions and 55 deletions
@@ -34,6 +34,7 @@ class SearchController extends Controller
Transfer::query()
->where('user_id', $account->id)
->shareTransfers()
->where('status', '!=', Transfer::STATUS_DELETED)
->where(function ($query) use ($like) {
$query->where('title', 'like', $like)
@@ -48,7 +48,12 @@ class FilesController extends Controller
->where('user_id', $account->id)
->where('status', '!=', Transfer::STATUS_DELETED)
->when($folderTransfer, fn ($inner) => $inner->whereKey($folderTransfer->id),
fn ($inner) => $inner->where('is_folder', false)))
fn ($inner) => $inner->where(function ($query) {
$query->where('is_root_storage', true)
->orWhere(function ($nested) {
$nested->where('is_folder', false)->where('is_root_storage', false);
});
})))
->when($search !== '', fn ($q) => $q->where('original_name', 'like', '%'.$search.'%'))
->with(['transfer.qrCode'])
->when($sort === 'oldest', fn ($q) => $q->oldest())
@@ -332,10 +337,11 @@ class FilesController extends Controller
}
$folder = $this->resolveUploadFolder($account, $data['folder'] ?? null);
$conflictLocation = $folder ?? $this->storage->rootStorage($account);
$conflicts = $this->storage->findConflicts(
$account,
$folder,
$conflictLocation,
$data['filenames'],
);
@@ -44,6 +44,7 @@ class OverviewController extends Controller
$recentTransfers = Transfer::query()
->where('user_id', $account->id)
->shareTransfers()
->accessible()
->with('qrCode')
->latest()
@@ -34,6 +34,7 @@ class TransferController extends Controller
$transfers = Transfer::query()
->where('user_id', $account->id)
->shareTransfers()
->where('status', '!=', Transfer::STATUS_DELETED)
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
@@ -207,5 +208,6 @@ class TransferController extends Controller
private function authorizeTransfer(Transfer $transfer): void
{
abort_unless($transfer->user_id === ladill_account()->id, 403);
abort_if($transfer->isStorageContainer(), 404);
}
}
+17
View File
@@ -166,6 +166,23 @@ class Transfer extends Model
return $query->where('is_folder', true);
}
public function scopeStorageContainers(Builder $query): Builder
{
return $query->where(function (Builder $inner) {
$inner->where('is_folder', true)->orWhere('is_root_storage', true);
});
}
public function scopeShareTransfers(Builder $query): Builder
{
return $query->where('is_folder', false)->where('is_root_storage', false);
}
public function isShareTransfer(): bool
{
return ! $this->is_folder && ! $this->is_root_storage;
}
public function isStorageContainer(): bool
{
return $this->is_folder || $this->is_root_storage;
+5 -4
View File
@@ -149,6 +149,7 @@ class FileStorageService
DB::transaction(function () use ($user, $destination, $files, $resolutions, $maxBytes, &$uploaded) {
$destination = Transfer::query()->lockForUpdate()->findOrFail($destination->id);
$wasEmpty = $destination->files()->count() === 0;
$location = $destination->isStorageContainer() ? $destination : null;
foreach ($files as $file) {
if (! $file instanceof UploadedFile) {
@@ -163,15 +164,15 @@ class FileStorageService
$resolution = $resolutions[$originalName] ?? null;
if ($resolution === 'replace') {
$existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName);
$existing = $this->findByName($user, $location, $originalName);
if ($existing) {
$this->transfers->deleteFile($existing);
}
$storedName = $originalName;
} elseif ($resolution === 'keep_both') {
$storedName = $this->uniqueFilename($user, $destination->is_folder ? $destination : null, $originalName);
$storedName = $this->uniqueFilename($user, $location, $originalName);
} else {
$existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName);
$existing = $this->findByName($user, $location, $originalName);
if ($existing) {
throw new RuntimeException("A file named \"{$originalName}\" already exists. Choose replace or keep both.");
}
@@ -205,7 +206,7 @@ class FileStorageService
if ($folder !== null) {
$query->whereKey($folder->id);
} else {
$query->where('is_folder', false);
$query->where('is_folder', false)->where('is_root_storage', false);
}
})
->first();
+64 -41
View File
@@ -312,6 +312,46 @@ function formatUploadBytes(bytes) {
return `${bytes} B`;
}
function uploadFileBasename(file) {
const relative = file.webkitRelativePath || '';
if (relative.includes('/')) {
return relative.split('/').pop() || file.name;
}
return (file.name.split(/[/\\]/).pop() || file.name);
}
function flattenFolderUploadFiles(files) {
const usedNames = new Set();
return files.map((file) => {
let name = uploadFileBasename(file);
if (usedNames.has(name)) {
const dot = name.lastIndexOf('.');
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let counter = 1;
do {
name = `${stem} (${counter})${ext}`;
counter += 1;
} while (usedNames.has(name));
}
usedNames.add(name);
if (name === file.name && !file.webkitRelativePath?.includes('/')) {
return file;
}
return new File([file], name, {
type: file.type,
lastModified: file.lastModified,
});
});
}
Alpine.data('transferCreateForm', (config = {}) => ({
fileItems: [],
submitting: false,
@@ -350,12 +390,7 @@ Alpine.data('transferCreateForm', (config = {}) => ({
return formatUploadBytes(bytes);
},
onFilesChange(event) {
if (this._ignoreInputChange) {
return;
}
const picked = Array.from(event.target.files || []);
queueFilesForUpload(picked) {
if (!picked.length) {
return;
}
@@ -382,12 +417,32 @@ Alpine.data('transferCreateForm', (config = {}) => ({
};
this.fileItems.push(item);
this._uploadQueue.push(item);
existingKeys.add(key);
}
this.resetFileInput(event.target);
void this.drainUploadQueue();
},
onFilesChange(event) {
if (this._ignoreInputChange) {
return;
}
const picked = Array.from(event.target.files || []);
this.resetFileInput(event.target);
this.queueFilesForUpload(picked);
},
onFolderChange(event) {
if (this._ignoreInputChange) {
return;
}
const picked = flattenFolderUploadFiles(Array.from(event.target.files || []));
this.resetFileInput(event.target);
this.queueFilesForUpload(picked);
},
resetFileInput(input) {
this._ignoreInputChange = true;
input.value = '';
@@ -747,43 +802,11 @@ Alpine.data('filesManager', (config = {}) => ({
},
fileBasename(file) {
const relative = file.webkitRelativePath || '';
if (relative.includes('/')) {
return relative.split('/').pop() || file.name;
}
return (file.name.split(/[/\\]/).pop() || file.name);
return uploadFileBasename(file);
},
flattenFolderUploadFiles(files) {
const usedNames = new Set();
return files.map((file) => {
let name = this.fileBasename(file);
if (usedNames.has(name)) {
const dot = name.lastIndexOf('.');
const stem = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : '';
let counter = 1;
do {
name = `${stem} (${counter})${ext}`;
counter += 1;
} while (usedNames.has(name));
}
usedNames.add(name);
if (name === file.name && !file.webkitRelativePath?.includes('/')) {
return file;
}
return new File([file], name, {
type: file.type,
lastModified: file.lastModified,
});
});
return flattenFolderUploadFiles(files);
},
cancelUploadConfirm() {
@@ -35,10 +35,22 @@
</div>
<div>
<label for="files" class="block text-sm font-medium text-slate-700">Files</label>
<input type="file" id="files" multiple @change="onFilesChange"
class="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100">
<p class="mt-1 text-xs text-slate-500">Up to {{ $maxFiles }} files. Uploads start immediately pick more files anytime before creating the transfer.</p>
<span class="block text-sm font-medium text-slate-700">Files</span>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button"
@click="$refs.fileInput.click()"
class="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Choose files
</button>
<button type="button"
@click="$refs.folderInput.click()"
class="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
Choose folder
</button>
</div>
<input type="file" x-ref="fileInput" id="files" multiple class="hidden" @change="onFilesChange">
<input type="file" x-ref="folderInput" multiple webkitdirectory class="hidden" @change="onFolderChange">
<p class="mt-1 text-xs text-slate-500">Up to {{ $maxFiles }} files. Uploads start immediately pick files or a folder anytime before creating the transfer.</p>
<div x-show="fileItems.length" x-cloak class="mt-3 space-y-2">
<template x-for="item in fileItems" :key="item.id">
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5">
+67 -4
View File
@@ -113,13 +113,72 @@ class TransferFilesTest extends TestCase
->assertSee('BKC');
}
public function test_transfers_index_excludes_files_page_storage(): void
{
Storage::fake('qr');
$this->fakeTransferBillingApi();
$user = $this->createUser();
$folder = $this->createFolder($user, 'Private folder');
$this->actingAs($user)
->postJson(route('transfer.files.upload'), [
'upload_folder_name' => 'Uploaded folder',
'files' => [UploadedFile::fake()->create('secret.txt', 10, 'text/plain')],
])
->assertOk();
$this->actingAs($user)
->post(route('transfer.transfers.store'), [
'title' => 'Shared deck',
'files' => [UploadedFile::fake()->create('deck.pdf', 50, 'application/pdf')],
])
->assertRedirect();
$this->actingAs($user)
->get(route('transfer.transfers.index'))
->assertOk()
->assertSee('Shared deck')
->assertDontSee('Private folder')
->assertDontSee('Uploaded folder');
}
public function test_files_index_shows_root_storage_and_share_files(): void
{
Storage::fake('qr');
$this->fakeTransferBillingApi();
$user = $this->createUser();
$this->actingAs($user)
->post(route('transfer.files.upload'), [
'files' => [UploadedFile::fake()->create('stored.txt', 10, 'text/plain')],
])
->assertRedirect();
$this->actingAs($user)
->post(route('transfer.transfers.store'), [
'title' => 'Shared deck',
'files' => [UploadedFile::fake()->create('deck.pdf', 50, 'application/pdf')],
])
->assertRedirect();
$this->actingAs($user)
->get(route('transfer.files.index'))
->assertOk()
->assertSee('stored.txt')
->assertSee('deck.pdf');
}
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)->post(route('transfer.files.upload'), [
'files' => [UploadedFile::fake()->create('brief.pdf', 50, 'application/pdf')],
])->assertRedirect();
$this->actingAs($user)
->postJson(route('transfer.files.upload.check'), [
@@ -135,7 +194,9 @@ class TransferFilesTest extends TestCase
$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', 50, 'application/pdf')],
])->assertRedirect();
$this->actingAs($user)
->post(route('transfer.files.upload'), [
@@ -144,7 +205,7 @@ class TransferFilesTest extends TestCase
])
->assertRedirect();
$this->assertSame(1, \App\Models\TransferFile::query()->count());
$this->assertSame(1, \App\Models\TransferFile::query()->where('original_name', 'brief.pdf')->count());
$this->assertDatabaseHas('transfer_files', ['original_name' => 'brief.pdf']);
}
@@ -154,7 +215,9 @@ class TransferFilesTest extends TestCase
$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', 50, 'application/pdf')],
])->assertRedirect();
$this->actingAs($user)
->post(route('transfer.files.upload'), [