Separate share transfers from Files storage and add folder upload to New transfer.
Deploy Ladill Transfer / deploy (push) Successful in 28s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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'), [
|
||||
|
||||
Reference in New Issue
Block a user