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() Transfer::query()
->where('user_id', $account->id) ->where('user_id', $account->id)
->shareTransfers()
->where('status', '!=', Transfer::STATUS_DELETED) ->where('status', '!=', Transfer::STATUS_DELETED)
->where(function ($query) use ($like) { ->where(function ($query) use ($like) {
$query->where('title', 'like', $like) $query->where('title', 'like', $like)
@@ -48,7 +48,12 @@ class FilesController extends Controller
->where('user_id', $account->id) ->where('user_id', $account->id)
->where('status', '!=', Transfer::STATUS_DELETED) ->where('status', '!=', Transfer::STATUS_DELETED)
->when($folderTransfer, fn ($inner) => $inner->whereKey($folderTransfer->id), ->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.'%')) ->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())
@@ -332,10 +337,11 @@ class FilesController extends Controller
} }
$folder = $this->resolveUploadFolder($account, $data['folder'] ?? null); $folder = $this->resolveUploadFolder($account, $data['folder'] ?? null);
$conflictLocation = $folder ?? $this->storage->rootStorage($account);
$conflicts = $this->storage->findConflicts( $conflicts = $this->storage->findConflicts(
$account, $account,
$folder, $conflictLocation,
$data['filenames'], $data['filenames'],
); );
@@ -44,6 +44,7 @@ class OverviewController extends Controller
$recentTransfers = Transfer::query() $recentTransfers = Transfer::query()
->where('user_id', $account->id) ->where('user_id', $account->id)
->shareTransfers()
->accessible() ->accessible()
->with('qrCode') ->with('qrCode')
->latest() ->latest()
@@ -34,6 +34,7 @@ class TransferController extends Controller
$transfers = Transfer::query() $transfers = Transfer::query()
->where('user_id', $account->id) ->where('user_id', $account->id)
->shareTransfers()
->where('status', '!=', Transfer::STATUS_DELETED) ->where('status', '!=', Transfer::STATUS_DELETED)
->when($search !== '', function ($query) use ($search) { ->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%'; $like = '%'.$search.'%';
@@ -207,5 +208,6 @@ class TransferController extends Controller
private function authorizeTransfer(Transfer $transfer): void private function authorizeTransfer(Transfer $transfer): void
{ {
abort_unless($transfer->user_id === ladill_account()->id, 403); 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); 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 public function isStorageContainer(): bool
{ {
return $this->is_folder || $this->is_root_storage; 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) { DB::transaction(function () use ($user, $destination, $files, $resolutions, $maxBytes, &$uploaded) {
$destination = Transfer::query()->lockForUpdate()->findOrFail($destination->id); $destination = Transfer::query()->lockForUpdate()->findOrFail($destination->id);
$wasEmpty = $destination->files()->count() === 0; $wasEmpty = $destination->files()->count() === 0;
$location = $destination->isStorageContainer() ? $destination : null;
foreach ($files as $file) { foreach ($files as $file) {
if (! $file instanceof UploadedFile) { if (! $file instanceof UploadedFile) {
@@ -163,15 +164,15 @@ class FileStorageService
$resolution = $resolutions[$originalName] ?? null; $resolution = $resolutions[$originalName] ?? null;
if ($resolution === 'replace') { if ($resolution === 'replace') {
$existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName); $existing = $this->findByName($user, $location, $originalName);
if ($existing) { if ($existing) {
$this->transfers->deleteFile($existing); $this->transfers->deleteFile($existing);
} }
$storedName = $originalName; $storedName = $originalName;
} elseif ($resolution === 'keep_both') { } elseif ($resolution === 'keep_both') {
$storedName = $this->uniqueFilename($user, $destination->is_folder ? $destination : null, $originalName); $storedName = $this->uniqueFilename($user, $location, $originalName);
} else { } else {
$existing = $this->findByName($user, $destination->is_folder ? $destination : null, $originalName); $existing = $this->findByName($user, $location, $originalName);
if ($existing) { if ($existing) {
throw new RuntimeException("A file named \"{$originalName}\" already exists. Choose replace or keep both."); throw new RuntimeException("A file named \"{$originalName}\" already exists. Choose replace or keep both.");
} }
@@ -205,7 +206,7 @@ class FileStorageService
if ($folder !== null) { if ($folder !== null) {
$query->whereKey($folder->id); $query->whereKey($folder->id);
} else { } else {
$query->where('is_folder', false); $query->where('is_folder', false)->where('is_root_storage', false);
} }
}) })
->first(); ->first();
+64 -41
View File
@@ -312,6 +312,46 @@ function formatUploadBytes(bytes) {
return `${bytes} B`; 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 = {}) => ({ Alpine.data('transferCreateForm', (config = {}) => ({
fileItems: [], fileItems: [],
submitting: false, submitting: false,
@@ -350,12 +390,7 @@ Alpine.data('transferCreateForm', (config = {}) => ({
return formatUploadBytes(bytes); return formatUploadBytes(bytes);
}, },
onFilesChange(event) { queueFilesForUpload(picked) {
if (this._ignoreInputChange) {
return;
}
const picked = Array.from(event.target.files || []);
if (!picked.length) { if (!picked.length) {
return; return;
} }
@@ -382,12 +417,32 @@ Alpine.data('transferCreateForm', (config = {}) => ({
}; };
this.fileItems.push(item); this.fileItems.push(item);
this._uploadQueue.push(item); this._uploadQueue.push(item);
existingKeys.add(key);
} }
this.resetFileInput(event.target);
void this.drainUploadQueue(); 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) { resetFileInput(input) {
this._ignoreInputChange = true; this._ignoreInputChange = true;
input.value = ''; input.value = '';
@@ -747,43 +802,11 @@ Alpine.data('filesManager', (config = {}) => ({
}, },
fileBasename(file) { fileBasename(file) {
const relative = file.webkitRelativePath || ''; return uploadFileBasename(file);
if (relative.includes('/')) {
return relative.split('/').pop() || file.name;
}
return (file.name.split(/[/\\]/).pop() || file.name);
}, },
flattenFolderUploadFiles(files) { flattenFolderUploadFiles(files) {
const usedNames = new Set(); return flattenFolderUploadFiles(files);
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,
});
});
}, },
cancelUploadConfirm() { cancelUploadConfirm() {
@@ -35,10 +35,22 @@
</div> </div>
<div> <div>
<label for="files" class="block text-sm font-medium text-slate-700">Files</label> <span class="block text-sm font-medium text-slate-700">Files</span>
<input type="file" id="files" multiple @change="onFilesChange" <div class="mt-2 flex flex-wrap gap-2">
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"> <button type="button"
<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> @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"> <div x-show="fileItems.length" x-cloak class="mt-3 space-y-2">
<template x-for="item in fileItems" :key="item.id"> <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"> <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'); ->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 public function test_upload_detects_duplicate_filenames(): void
{ {
Storage::fake('qr'); Storage::fake('qr');
$this->fakeTransferBillingApi(); $this->fakeTransferBillingApi();
$user = $this->createUser(); $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) $this->actingAs($user)
->postJson(route('transfer.files.upload.check'), [ ->postJson(route('transfer.files.upload.check'), [
@@ -135,7 +194,9 @@ class TransferFilesTest extends TestCase
$this->fakeTransferBillingApi(); $this->fakeTransferBillingApi();
$user = $this->createUser(); $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) $this->actingAs($user)
->post(route('transfer.files.upload'), [ ->post(route('transfer.files.upload'), [
@@ -144,7 +205,7 @@ class TransferFilesTest extends TestCase
]) ])
->assertRedirect(); ->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']); $this->assertDatabaseHas('transfer_files', ['original_name' => 'brief.pdf']);
} }
@@ -154,7 +215,9 @@ class TransferFilesTest extends TestCase
$this->fakeTransferBillingApi(); $this->fakeTransferBillingApi();
$user = $this->createUser(); $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) $this->actingAs($user)
->post(route('transfer.files.upload'), [ ->post(route('transfer.files.upload'), [