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:
@@ -4,7 +4,6 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Services\Transfer\TransferBillingService;
|
use App\Services\Transfer\TransferBillingService;
|
||||||
use App\Services\Transfer\TransferOwnerMailService;
|
use App\Services\Transfer\TransferOwnerMailService;
|
||||||
use App\Services\Transfer\TransferRecipientMailService;
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class ProcessTransferBillingCommand extends Command
|
class ProcessTransferBillingCommand extends Command
|
||||||
@@ -15,19 +14,16 @@ class ProcessTransferBillingCommand extends Command
|
|||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
TransferBillingService $billing,
|
TransferBillingService $billing,
|
||||||
TransferRecipientMailService $recipients,
|
|
||||||
TransferOwnerMailService $owners,
|
TransferOwnerMailService $owners,
|
||||||
): int {
|
): int {
|
||||||
$result = $billing->processDueRenewals();
|
$result = $billing->processDueRenewals();
|
||||||
$recipientEmails = $recipients->processDueMilestones();
|
|
||||||
$ownerEmails = $owners->processGraceReminders();
|
$ownerEmails = $owners->processGraceReminders();
|
||||||
|
|
||||||
$this->info(sprintf(
|
$this->info(sprintf(
|
||||||
'Transfer billing: %d renewed, %d entered grace, %d deleted, %d recipient reminders, %d owner grace reminders.',
|
'Transfer billing: %d renewed, %d entered grace, %d deleted, %d owner grace reminders.',
|
||||||
$result['renewed'],
|
$result['renewed'],
|
||||||
$result['graced'],
|
$result['graced'],
|
||||||
$result['deleted'],
|
$result['deleted'],
|
||||||
$recipientEmails,
|
|
||||||
$ownerEmails,
|
$ownerEmails,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\Transfer;
|
use App\Models\Transfer;
|
||||||
use App\Services\Qr\QrImageGeneratorService;
|
use App\Services\Qr\QrImageGeneratorService;
|
||||||
use App\Services\Qr\QrPdfExporter;
|
use App\Services\Qr\QrPdfExporter;
|
||||||
use App\Services\Transfer\TransferRecipientMailService;
|
|
||||||
use App\Services\Transfer\TransferService;
|
use App\Services\Transfer\TransferService;
|
||||||
use App\Services\Upload\ChunkedUploadService;
|
use App\Services\Upload\ChunkedUploadService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -42,14 +41,10 @@ class TransferController extends Controller
|
|||||||
|
|
||||||
public function create(): View
|
public function create(): View
|
||||||
{
|
{
|
||||||
$mailService = app(TransferRecipientMailService::class);
|
|
||||||
|
|
||||||
return view('transfer.transfers.create', [
|
return view('transfer.transfers.create', [
|
||||||
'maxFiles' => (int) config('transfer.max_files_per_transfer', 20),
|
'maxFiles' => (int) config('transfer.max_files_per_transfer', 20),
|
||||||
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
|
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
|
||||||
'gracePeriodDays' => (int) config('transfer.grace_period_days', 15),
|
'gracePeriodDays' => (int) config('transfer.grace_period_days', 15),
|
||||||
'emailMilestones' => $mailService->milestoneOptions(),
|
|
||||||
'defaultEmailMilestones' => (array) config('transfer.default_recipient_email_milestones', ['created']),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,14 +58,10 @@ class TransferController extends Controller
|
|||||||
$fileRules[] = 'max:'.(int) ceil($maxBytes / 1024);
|
$fileRules[] = 'max:'.(int) ceil($maxBytes / 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
$allowedMilestones = implode(',', app(TransferRecipientMailService::class)->allowedMilestones());
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'title' => 'required|string|max:120',
|
'title' => 'required|string|max:120',
|
||||||
'message' => 'nullable|string|max:2000',
|
'message' => 'nullable|string|max:2000',
|
||||||
'recipient_email' => 'nullable|email|max:255|required_with:email_milestones',
|
'recipient_email' => 'nullable|email|max:255',
|
||||||
'email_milestones' => 'nullable|array',
|
|
||||||
'email_milestones.*' => 'in:'.$allowedMilestones,
|
|
||||||
'password' => 'nullable|string|min:4|max:64',
|
'password' => 'nullable|string|min:4|max:64',
|
||||||
'files' => 'nullable|array',
|
'files' => 'nullable|array',
|
||||||
'files.*' => $fileRules,
|
'files.*' => $fileRules,
|
||||||
@@ -105,7 +96,7 @@ class TransferController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$success = 'Transfer created. Share the link or QR code with recipients.';
|
$success = 'Transfer created. Share the link or QR code with recipients.';
|
||||||
if ($transfer->recipient_email && $transfer->wantsRecipientMilestone('created') && $transfer->recipientMilestoneSent('created')) {
|
if ($transfer->recipient_email && $transfer->recipientMilestoneSent('created')) {
|
||||||
$success .= ' A download link was emailed to '.$transfer->recipient_email.'.';
|
$success .= ' A download link was emailed to '.$transfer->recipient_email.'.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,96 +4,32 @@ namespace App\Services\Transfer;
|
|||||||
|
|
||||||
use App\Models\Transfer;
|
use App\Models\Transfer;
|
||||||
use App\Notifications\TransferRecipientNotification;
|
use App\Notifications\TransferRecipientNotification;
|
||||||
use Carbon\CarbonInterface;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
|
||||||
/** Sends milestone emails to transfer recipients (share link + reminders). */
|
/** Sends the immediate download-link email to transfer recipients. */
|
||||||
class TransferRecipientMailService
|
class TransferRecipientMailService
|
||||||
{
|
{
|
||||||
/** @return list<string> */
|
public function notifyRecipient(Transfer $transfer, string $milestone = 'created'): bool
|
||||||
public function allowedMilestones(): array
|
|
||||||
{
|
{
|
||||||
return array_keys((array) config('transfer.recipient_email_milestones', []));
|
if ($milestone !== 'created') {
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, string> */
|
|
||||||
public function milestoneOptions(): array
|
|
||||||
{
|
|
||||||
return (array) config('transfer.recipient_email_milestones', []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param list<string> $milestones */
|
|
||||||
public function normalizeMilestones(array $milestones): array
|
|
||||||
{
|
|
||||||
$allowed = $this->allowedMilestones();
|
|
||||||
|
|
||||||
return array_values(array_unique(array_filter(
|
|
||||||
$milestones,
|
|
||||||
static fn ($milestone) => in_array((string) $milestone, $allowed, true),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function notifyRecipient(Transfer $transfer, string $milestone): bool
|
|
||||||
{
|
|
||||||
$email = trim((string) $transfer->recipient_email);
|
|
||||||
if ($email === '' || ! $transfer->wantsRecipientMilestone($milestone)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($transfer->recipientMilestoneSent($milestone)) {
|
$email = trim((string) $transfer->recipient_email);
|
||||||
|
if ($email === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($transfer->recipientMilestoneSent('created')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::route('mail', $email)->notify(
|
Notification::route('mail', $email)->notify(
|
||||||
new TransferRecipientNotification($transfer->loadMissing(['files', 'qrCode', 'user']), $milestone),
|
new TransferRecipientNotification($transfer->loadMissing(['files', 'qrCode', 'user']), 'created'),
|
||||||
);
|
);
|
||||||
|
|
||||||
$transfer->markRecipientMilestoneSent($milestone);
|
$transfer->markRecipientMilestoneSent('created');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function processDueMilestones(): int
|
|
||||||
{
|
|
||||||
$sent = 0;
|
|
||||||
|
|
||||||
Transfer::query()
|
|
||||||
->accessible()
|
|
||||||
->whereNotNull('recipient_email')
|
|
||||||
->whereNotNull('recipient_email_milestones')
|
|
||||||
->with(['files', 'qrCode', 'user'])
|
|
||||||
->chunkById(100, function ($transfers) use (&$sent) {
|
|
||||||
foreach ($transfers as $transfer) {
|
|
||||||
foreach ($this->dueDayMilestones($transfer) as $milestone) {
|
|
||||||
if ($this->notifyRecipient($transfer, $milestone)) {
|
|
||||||
$sent++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $sent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
private function dueDayMilestones(Transfer $transfer): array
|
|
||||||
{
|
|
||||||
if (! $transfer->paid_until instanceof CarbonInterface || $transfer->paid_until->isPast()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$daysRemaining = (int) now()->startOfDay()->diffInDays(
|
|
||||||
$transfer->paid_until->copy()->startOfDay(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
$due = [];
|
|
||||||
foreach (['7', '3', '1'] as $milestone) {
|
|
||||||
if ($daysRemaining === (int) $milestone && $transfer->wantsRecipientMilestone($milestone)) {
|
|
||||||
$due[] = $milestone;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $due;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,22 +48,13 @@ class TransferService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$recipientEmail = trim((string) ($data['recipient_email'] ?? ''));
|
$recipientEmail = trim((string) ($data['recipient_email'] ?? ''));
|
||||||
$recipientMilestones = app(TransferRecipientMailService::class)->normalizeMilestones(
|
|
||||||
is_array($data['email_milestones'] ?? null) ? $data['email_milestones'] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($recipientEmail !== '' && $recipientMilestones === []) {
|
|
||||||
$recipientMilestones = app(TransferRecipientMailService::class)->normalizeMilestones(
|
|
||||||
(array) config('transfer.default_recipient_email_milestones', ['created']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$transfer = Transfer::create([
|
$transfer = Transfer::create([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'title' => trim((string) $data['title']),
|
'title' => trim((string) $data['title']),
|
||||||
'message' => isset($data['message']) ? trim((string) $data['message']) : null,
|
'message' => isset($data['message']) ? trim((string) $data['message']) : null,
|
||||||
'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null,
|
'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null,
|
||||||
'recipient_email_milestones' => $recipientEmail !== '' ? $recipientMilestones : null,
|
'recipient_email_milestones' => $recipientEmail !== '' ? ['created'] : null,
|
||||||
'recipient_milestones_sent' => [],
|
'recipient_milestones_sent' => [],
|
||||||
'owner_milestones_sent' => [],
|
'owner_milestones_sent' => [],
|
||||||
'password_hash' => $passwordHash,
|
'password_hash' => $passwordHash,
|
||||||
|
|||||||
@@ -29,17 +29,6 @@ return [
|
|||||||
// Days to keep files after a failed renewal before automatic deletion.
|
// Days to keep files after a failed renewal before automatic deletion.
|
||||||
'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15),
|
'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15),
|
||||||
|
|
||||||
// Recipient email milestones (checkboxes on the create transfer form).
|
|
||||||
'recipient_email_milestones' => [
|
|
||||||
'created' => 'Send download link immediately',
|
|
||||||
'7' => '7 days before files become unavailable',
|
|
||||||
'3' => '3 days before files become unavailable',
|
|
||||||
'1' => '1 day before files become unavailable',
|
|
||||||
'grace' => 'When files enter the payment grace period',
|
|
||||||
],
|
|
||||||
|
|
||||||
'default_recipient_email_milestones' => ['created'],
|
|
||||||
|
|
||||||
// Owner emails when a transfer enters grace and before files are deleted.
|
// Owner emails when a transfer enters grace and before files are deleted.
|
||||||
'owner_grace_email_milestones' => ['grace_entered', '7', '3', '1'],
|
'owner_grace_email_milestones' => ['grace_entered', '7', '3', '1'],
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "ladill-qr-plus",
|
"name": "ladill-transfer",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|||||||
@@ -15,6 +15,41 @@
|
|||||||
display: none !important;
|
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) */
|
/* QR create/show: flush mobile action bar to the physical screen bottom (mobile only) */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.mobile-action-bar {
|
.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 = {}) => ({
|
Alpine.data('transferCreateForm', (config = {}) => ({
|
||||||
selectedFiles: [],
|
fileItems: [],
|
||||||
uploading: false,
|
|
||||||
submitting: false,
|
submitting: false,
|
||||||
uploadProgress: 0,
|
|
||||||
uploadLabel: '',
|
|
||||||
prepared: false,
|
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) {
|
onFilesChange(event) {
|
||||||
this.selectedFiles = Array.from(event.target.files || []);
|
const newFiles = Array.from(event.target.files || []);
|
||||||
this.prepared = false;
|
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) {
|
async submit(event) {
|
||||||
@@ -322,66 +455,49 @@ Alpine.data('transferCreateForm', (config = {}) => ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
const form = event.target;
|
||||||
const fileInput = form.querySelector('#files');
|
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.');
|
window.alert('Add at least one file to share.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.uploading = true;
|
if (this.fileItems.some((item) => item.status === 'error')) {
|
||||||
this.uploadProgress = 0;
|
window.alert('Remove failed uploads or choose the files again.');
|
||||||
const smallFiles = [];
|
return;
|
||||||
|
|
||||||
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.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>
|
<label for="files" class="block text-sm font-medium text-slate-700">Files</label>
|
||||||
<input type="file" id="files" multiple required @change="onFilesChange"
|
<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">
|
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>
|
<p class="mt-1 text-xs text-slate-500">Up to {{ $maxFiles }} files. Large files upload in the background with live progress.</p>
|
||||||
<template x-if="uploading">
|
<div x-show="fileItems.length" x-cloak class="mt-3 space-y-2">
|
||||||
<div class="mt-2">
|
<template x-for="item in fileItems" :key="item.id">
|
||||||
<div class="h-2 w-full overflow-hidden rounded-full bg-slate-100">
|
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5">
|
||||||
<div class="h-full rounded-full bg-indigo-600 transition-all" :style="`width: ${uploadProgress}%`"></div>
|
<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>
|
</div>
|
||||||
<p class="mt-1 text-xs text-slate-500" x-text="uploadLabel"></p>
|
|
||||||
</div>
|
</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
|
||||||
@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>
|
</div>
|
||||||
@@ -57,19 +90,7 @@
|
|||||||
placeholder="friend@example.com"
|
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">
|
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
|
@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>
|
<p class="mt-1 text-xs text-slate-500">A download link is emailed immediately when you create the transfer.</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
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600">
|
<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)
|
@if($transfer->recipient_email)
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
<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>
|
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>
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\Transfer;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Notifications\TransferRecipientNotification;
|
use App\Notifications\TransferRecipientNotification;
|
||||||
use App\Services\Transfer\TransferRecipientMailService;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
@@ -19,22 +16,17 @@ class TransferRecipientMailTest extends TestCase
|
|||||||
use FakesTransferBilling;
|
use FakesTransferBilling;
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_create_transfer_emails_recipient_when_created_milestone_selected(): void
|
public function test_create_transfer_emails_recipient_immediately(): void
|
||||||
{
|
{
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
Storage::fake('qr');
|
Storage::fake('qr');
|
||||||
$this->fakeTransferBillingApi();
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
$user = User::create([
|
$user = $this->makeUser();
|
||||||
'public_id' => (string) Str::uuid(),
|
|
||||||
'name' => 'Sender',
|
|
||||||
'email' => 'sender+'.uniqid().'@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)->post(route('transfer.transfers.store'), [
|
$this->actingAs($user)->post(route('transfer.transfers.store'), [
|
||||||
'title' => 'Project files',
|
'title' => 'Project files',
|
||||||
'recipient_email' => 'recipient@example.com',
|
'recipient_email' => 'recipient@example.com',
|
||||||
'email_milestones' => ['created'],
|
|
||||||
'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')],
|
'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')],
|
||||||
])->assertRedirect();
|
])->assertRedirect();
|
||||||
|
|
||||||
@@ -44,35 +36,28 @@ class TransferRecipientMailTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_recipient_day_milestone_is_sent_once(): void
|
public function test_create_transfer_without_recipient_email_sends_no_notification(): void
|
||||||
{
|
{
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
|
Storage::fake('qr');
|
||||||
|
$this->fakeTransferBillingApi();
|
||||||
|
|
||||||
$user = User::create([
|
$user = $this->makeUser();
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('transfer.transfers.store'), [
|
||||||
|
'title' => 'Project files',
|
||||||
|
'files' => [UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf')],
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUser()
|
||||||
|
{
|
||||||
|
return \App\Models\User::create([
|
||||||
'public_id' => (string) Str::uuid(),
|
'public_id' => (string) Str::uuid(),
|
||||||
'name' => 'Sender',
|
'name' => 'Sender',
|
||||||
'email' => 'sender+'.uniqid().'@example.com',
|
'email' => 'sender+'.uniqid().'@example.com',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$transfer = Transfer::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'title' => 'Reminder test',
|
|
||||||
'recipient_email' => 'recipient@example.com',
|
|
||||||
'recipient_email_milestones' => ['7'],
|
|
||||||
'recipient_milestones_sent' => [],
|
|
||||||
'retention_days' => 30,
|
|
||||||
'paid_until' => now()->addDays(7)->startOfDay(),
|
|
||||||
'status' => Transfer::STATUS_ACTIVE,
|
|
||||||
'storage_bytes' => 1024,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$service = app(TransferRecipientMailService::class);
|
|
||||||
$this->assertSame(1, $service->processDueMilestones());
|
|
||||||
$this->assertSame(0, $service->processDueMilestones());
|
|
||||||
|
|
||||||
$transfer->refresh();
|
|
||||||
$this->assertContains('7', $transfer->recipient_milestones_sent ?? []);
|
|
||||||
|
|
||||||
Notification::assertSentOnDemand(TransferRecipientNotification::class, 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user