Simplify recipient emails and add live upload progress on create.
Deploy Ladill Transfer / deploy (push) Successful in 47s
Deploy Ladill Transfer / deploy (push) Successful in 47s
Recipients always get the download link immediately when an email is set. Large files upload in the background with per-file and overall progress bars. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,6 +15,41 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@keyframes upload-progress-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress-track {
|
||||
height: 0.375rem;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 9999px;
|
||||
background-color: rgb(226 232 240);
|
||||
}
|
||||
|
||||
.upload-progress-bar {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(90deg, #6366f1 0%, #4f46e5 100%);
|
||||
transition: width 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.35), transparent);
|
||||
animation: upload-progress-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */
|
||||
@media (max-width: 1023px) {
|
||||
.mobile-action-bar {
|
||||
|
||||
+175
-59
@@ -301,17 +301,150 @@ Alpine.data('topbarSearch', (config = {}) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function formatUploadBytes(bytes) {
|
||||
if (bytes >= 1048576) {
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
if (bytes >= 1024) {
|
||||
return `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
Alpine.data('transferCreateForm', (config = {}) => ({
|
||||
selectedFiles: [],
|
||||
uploading: false,
|
||||
fileItems: [],
|
||||
submitting: false,
|
||||
uploadProgress: 0,
|
||||
uploadLabel: '',
|
||||
prepared: false,
|
||||
_uploadQueue: Promise.resolve(),
|
||||
|
||||
get uploading() {
|
||||
return this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending');
|
||||
},
|
||||
|
||||
get overallProgress() {
|
||||
const items = this.fileItems.filter((item) => item.status !== 'error');
|
||||
if (!items.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const total = items.reduce((sum, item) => sum + item.size, 0);
|
||||
const done = items.reduce((sum, item) => {
|
||||
if (item.status === 'ready') {
|
||||
return sum + item.size;
|
||||
}
|
||||
if (item.status === 'uploading') {
|
||||
return sum + (item.size * (item.progress || 0) / 100);
|
||||
}
|
||||
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
return total ? Math.round((done / total) * 100) : 0;
|
||||
},
|
||||
|
||||
formatBytes(bytes) {
|
||||
return formatUploadBytes(bytes);
|
||||
},
|
||||
|
||||
onFilesChange(event) {
|
||||
this.selectedFiles = Array.from(event.target.files || []);
|
||||
const newFiles = Array.from(event.target.files || []);
|
||||
this.prepared = false;
|
||||
|
||||
const prevByKey = new Map(this.fileItems.map((item) => [item.key, item]));
|
||||
this.fileItems = [];
|
||||
|
||||
for (const file of newFiles) {
|
||||
const key = `${file.name}:${file.size}:${file.lastModified}`;
|
||||
const prev = prevByKey.get(key);
|
||||
if (prev && (prev.status === 'uploading' || prev.status === 'ready')) {
|
||||
this.fileItems.push(prev);
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = {
|
||||
id: (crypto.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random()}`,
|
||||
key,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: shouldUseChunkedUpload(file.size) ? 'pending' : 'ready',
|
||||
progress: 0,
|
||||
uploadId: prev?.uploadId || null,
|
||||
error: null,
|
||||
};
|
||||
this.fileItems.push(item);
|
||||
|
||||
if (item.status === 'pending') {
|
||||
this.queueUpload(item);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncFileInput(event.target);
|
||||
},
|
||||
|
||||
queueUpload(item) {
|
||||
this._uploadQueue = this._uploadQueue
|
||||
.then(() => this.uploadFileItem(item))
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
async uploadFileItem(item) {
|
||||
if (item.status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
item.status = 'uploading';
|
||||
item.progress = 0;
|
||||
|
||||
try {
|
||||
const result = await uploadFileChunked(item.file, {
|
||||
initUrl: config.initUrl,
|
||||
chunkUrl: config.chunkUrl,
|
||||
finalizeUrl: config.finalizeUrl,
|
||||
csrfToken: config.csrfToken,
|
||||
onProgress: (percent) => {
|
||||
item.progress = percent;
|
||||
},
|
||||
});
|
||||
|
||||
item.uploadId = result.uploadId;
|
||||
item.status = 'ready';
|
||||
item.progress = 100;
|
||||
} catch (error) {
|
||||
item.status = 'error';
|
||||
item.error = error?.message || 'Upload failed.';
|
||||
}
|
||||
},
|
||||
|
||||
removeFileItem(id) {
|
||||
const item = this.fileItems.find((entry) => entry.id === id);
|
||||
if (!item || item.status === 'uploading') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fileItems = this.fileItems.filter((entry) => entry.id !== id);
|
||||
this.syncFileInput(document.querySelector('#files'));
|
||||
},
|
||||
|
||||
syncFileInput(input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const smallFiles = this.fileItems
|
||||
.filter((item) => item.status === 'ready' && item.file && !shouldUseChunkedUpload(item.size))
|
||||
.map((item) => item.file);
|
||||
|
||||
if (smallFiles.length) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
smallFiles.forEach((file) => dataTransfer.items.add(file));
|
||||
input.files = dataTransfer.files;
|
||||
input.required = true;
|
||||
} else {
|
||||
input.removeAttribute('required');
|
||||
input.value = '';
|
||||
}
|
||||
},
|
||||
|
||||
async submit(event) {
|
||||
@@ -322,66 +455,49 @@ Alpine.data('transferCreateForm', (config = {}) => ({
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const fileInput = form.querySelector('#files');
|
||||
const files = this.selectedFiles.length
|
||||
? this.selectedFiles
|
||||
: Array.from(fileInput?.files || []);
|
||||
|
||||
if (!files.length) {
|
||||
if (!this.fileItems.length) {
|
||||
window.alert('Add at least one file to share.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
this.uploadProgress = 0;
|
||||
const smallFiles = [];
|
||||
|
||||
try {
|
||||
form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove());
|
||||
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
const file = files[index];
|
||||
this.uploadLabel = `Uploading ${file.name} (${index + 1}/${files.length})…`;
|
||||
|
||||
if (shouldUseChunkedUpload(file.size)) {
|
||||
const result = await uploadFileChunked(file, {
|
||||
initUrl: config.initUrl,
|
||||
chunkUrl: config.chunkUrl,
|
||||
finalizeUrl: config.finalizeUrl,
|
||||
csrfToken: config.csrfToken,
|
||||
onProgress: (percent) => {
|
||||
this.uploadProgress = percent;
|
||||
},
|
||||
});
|
||||
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'upload_ids[]';
|
||||
hidden.value = result.uploadId;
|
||||
form.appendChild(hidden);
|
||||
} else {
|
||||
smallFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileInput && smallFiles.length) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
smallFiles.forEach((file) => dataTransfer.items.add(file));
|
||||
fileInput.files = dataTransfer.files;
|
||||
} else if (fileInput) {
|
||||
fileInput.removeAttribute('required');
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
this.uploading = false;
|
||||
this.submitting = true;
|
||||
this.prepared = true;
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
this.uploading = false;
|
||||
this.submitting = false;
|
||||
this.prepared = false;
|
||||
window.alert(error?.message || 'Upload failed. Please try again.');
|
||||
if (this.fileItems.some((item) => item.status === 'error')) {
|
||||
window.alert('Remove failed uploads or choose the files again.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.uploading) {
|
||||
await this._uploadQueue;
|
||||
}
|
||||
|
||||
if (this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending')) {
|
||||
window.alert('Please wait for uploads to finish.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.fileItems.some((item) => item.status === 'ready')) {
|
||||
window.alert('Add at least one file to share.');
|
||||
return;
|
||||
}
|
||||
|
||||
form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove());
|
||||
|
||||
for (const item of this.fileItems) {
|
||||
if (!item.uploadId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'upload_ids[]';
|
||||
hidden.value = item.uploadId;
|
||||
form.appendChild(hidden);
|
||||
}
|
||||
|
||||
this.syncFileInput(fileInput);
|
||||
this.submitting = true;
|
||||
this.prepared = true;
|
||||
form.submit();
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,15 +38,48 @@
|
||||
<label for="files" class="block text-sm font-medium text-slate-700">Files</label>
|
||||
<input type="file" id="files" multiple required @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. Large files upload in small chunks — no practical size limit.</p>
|
||||
<template x-if="uploading">
|
||||
<div class="mt-2">
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full bg-indigo-600 transition-all" :style="`width: ${uploadProgress}%`"></div>
|
||||
<p class="mt-1 text-xs text-slate-500">Up to {{ $maxFiles }} files. Large files upload in the background with live progress.</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">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-800" x-text="item.name"></p>
|
||||
<p class="text-xs text-slate-500" x-text="formatBytes(item.size)"></p>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs font-medium"
|
||||
:class="{
|
||||
'text-indigo-600': item.status === 'uploading' || item.status === 'pending',
|
||||
'text-emerald-600': item.status === 'ready',
|
||||
'text-red-600': item.status === 'error',
|
||||
}"
|
||||
x-text="item.status === 'uploading' ? `${item.progress}%` : (item.status === 'pending' ? 'Waiting…' : (item.status === 'ready' ? 'Ready' : 'Failed'))"></span>
|
||||
<button type="button"
|
||||
@click="removeFileItem(item.id)"
|
||||
:disabled="item.status === 'uploading'"
|
||||
class="shrink-0 rounded p-0.5 text-slate-400 hover:bg-white hover:text-rose-600 disabled:opacity-40"
|
||||
title="Remove">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="item.status === 'uploading'" class="mt-2">
|
||||
<div class="upload-progress-track">
|
||||
<div class="upload-progress-bar" :style="`width: ${item.progress}%`"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="item.status === 'error'" class="mt-1 text-xs text-red-600" x-text="item.error"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="uploading" class="rounded-xl border border-indigo-100 bg-indigo-50/70 px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-2 text-xs text-indigo-800">
|
||||
<span class="font-medium">Uploading files…</span>
|
||||
<span x-text="`${overallProgress}%`"></span>
|
||||
</div>
|
||||
<div class="upload-progress-track mt-2">
|
||||
<div class="upload-progress-bar" :style="`width: ${overallProgress}%`"></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500" x-text="uploadLabel"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@error('files')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
@error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
@@ -57,19 +90,7 @@
|
||||
placeholder="friend@example.com"
|
||||
class="mt-1 block w-full rounded-xl border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@error('recipient_email')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
<p class="mt-2 text-xs font-medium text-slate-600">Email milestones</p>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach($emailMilestones as $id => $label)
|
||||
<label class="flex items-start gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="email_milestones[]" value="{{ $id }}"
|
||||
@checked(in_array($id, old('email_milestones', $defaultEmailMilestones), true))
|
||||
class="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
||||
<span>{{ $label }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('email_milestones')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
@error('email_milestones.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
<p class="mt-1 text-xs text-slate-500">A download link is emailed immediately when you create the transfer.</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
@if($transfer->recipient_email)
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
Recipient: <span class="font-medium text-slate-900">{{ $transfer->recipient_email }}</span>
|
||||
@if(is_array($transfer->recipient_email_milestones) && $transfer->recipient_email_milestones !== [])
|
||||
@if($transfer->recipientMilestoneSent('created'))
|
||||
<span class="text-slate-400">·</span>
|
||||
Notified on {{ implode(', ', array_map(fn ($m) => $m === 'created' ? 'share' : $m, $transfer->recipient_email_milestones)) }}
|
||||
Download link emailed
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
Reference in New Issue
Block a user