Files
ladill-transfer/resources/js/app.js
T
isaaccladandCursor 4b616a7f04
Deploy Ladill Transfer / deploy (push) Successful in 47s
Simplify recipient emails and add live upload progress on create.
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>
2026-06-08 13:25:43 +00:00

508 lines
16 KiB
JavaScript

import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { shouldUseChunkedUpload, uploadFileChunked } from './chunked-upload';
registerLadillModalHelpers();
import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';
import QRCodeStyling from 'qr-code-styling';
window.QRCodeStyling = QRCodeStyling;
import qrcode from 'qrcode-generator';
window.qrcode = qrcode;
Alpine.plugin(collapse);
Alpine.data('notificationDropdown', (config = {}) => ({
open: false,
loading: false,
notifications: [],
unreadCount: 0,
unreadUrl: config.unreadUrl || '/notifications/unread',
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
indexUrl: config.indexUrl || '/notifications',
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
init() {
this.fetchUnread();
setInterval(() => this.fetchUnread(), 60000);
},
async fetchUnread() {
try {
const res = await fetch(this.unreadUrl, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await res.json();
this.notifications = data.notifications || [];
this.unreadCount = data.unread_count || 0;
} catch (e) {
console.error('Failed to fetch notifications', e);
}
},
toggle() {
this.open = !this.open;
if (this.open) {
this.loading = true;
this.fetchUnread().finally(() => { this.loading = false; });
}
},
async markRead(id) {
const url = this.markReadUrl.replace('__ID__', id);
try {
await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
});
this.notifications = this.notifications.filter(n => n.id !== id);
this.unreadCount = Math.max(0, this.unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
},
async markAllRead() {
try {
await fetch(this.markAllReadUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
});
this.notifications = [];
this.unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
},
getIconBg(icon) {
const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' };
return map[icon] || 'bg-slate-100';
},
getIconColor(icon) {
const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' };
return map[icon] || 'text-slate-500';
},
}));
// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button
// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade.
Alpine.data('afia', (config = {}) => ({
open: false,
input: '',
loading: false,
messages: [
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
],
suggestions: config.suggestions || [],
init() {
window.addEventListener('afia-open', () => {
this.open = true;
this.$nextTick(() => this.$refs.input && this.$refs.input.focus());
});
},
close() { this.open = false; },
useSuggestion(s) { this.input = s; this.send(); },
scrollDown() {
this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; });
},
async send() {
const text = this.input.trim();
if (!text || this.loading) return;
const history = this.messages.map((m) => ({ role: m.role, text: m.text }));
this.messages.push({ role: 'user', text });
this.input = '';
this.loading = true;
this.scrollDown();
try {
const res = await fetch(config.chatUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' },
body: JSON.stringify({ message: text, history }),
});
const data = await res.json();
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
} catch (e) {
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
}
this.loading = false;
this.scrollDown();
},
}));
function mobileKeyboardBottomOffset() {
const viewport = window.visualViewport;
if (!viewport) {
return 0;
}
return Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop));
}
Alpine.data('miniPaymentLanding', (config = {}) => ({
amount: config.amount ?? '',
loading: false,
errorMsg: config.errorMsg ?? '',
showSheet: false,
checkoutUrl: '',
paymentSheetStyle: '',
sheetBleedStyle: '',
init() {
this._syncSheet = () => {
if (window.innerWidth >= 768) {
this.paymentSheetStyle = '';
this.sheetBleedStyle = '';
return;
}
const offset = mobileKeyboardBottomOffset();
const safePad = offset > 0 ? '1.25rem' : 'max(1.25rem, env(safe-area-inset-bottom))';
this.paymentSheetStyle = `bottom: ${offset}px; padding-bottom: ${safePad};`;
this.sheetBleedStyle = `bottom: ${offset}px;`;
};
this._onViewportChange = () => this._syncSheet();
this._onFocusChange = () => {
requestAnimationFrame(this._syncSheet);
setTimeout(this._syncSheet, 150);
setTimeout(this._syncSheet, 350);
};
window.visualViewport?.addEventListener('resize', this._onViewportChange);
window.visualViewport?.addEventListener('scroll', this._onViewportChange);
document.addEventListener('focusin', this._onFocusChange);
document.addEventListener('focusout', this._onFocusChange);
if (window.innerWidth < 768) {
document.documentElement.classList.add('mini-payment-page');
}
this._syncSheet();
},
destroy() {
window.visualViewport?.removeEventListener('resize', this._onViewportChange);
window.visualViewport?.removeEventListener('scroll', this._onViewportChange);
document.removeEventListener('focusin', this._onFocusChange);
document.removeEventListener('focusout', this._onFocusChange);
document.documentElement.classList.remove('mini-payment-page');
},
async submitPay() {
const value = parseFloat(this.amount);
if (!value || value <= 0) {
this.errorMsg = 'Enter an amount greater than zero.';
return;
}
this.errorMsg = '';
this.loading = true;
try {
const res = await fetch(config.payUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': config.csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ amount: value }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error || data.message) {
this.errorMsg = data.error || data.message || 'Could not start payment. Please try again.';
this.loading = false;
return;
}
if (!data.checkout_url) {
this.errorMsg = 'Could not start payment. Please try again.';
this.loading = false;
return;
}
if (window.innerWidth < 768) {
this.checkoutUrl = data.checkout_url;
this.showSheet = true;
this.loading = false;
} else {
window.location.href = data.checkout_url;
}
} catch (e) {
this.errorMsg = 'Network error. Please try again.';
this.loading = false;
}
},
}));
Alpine.data('topbarSearch', (config = {}) => ({
query: config.initialQuery || '',
results: Array.isArray(config.initialResults) ? config.initialResults : [],
open: !!config.openOnInit,
loading: false,
active: 0,
_abort: null,
searchUrl: config.searchUrl || '/search',
init() {
if (config.autoFocus) {
this.$nextTick(() => this.$refs.input?.focus());
}
},
onFocus() {
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
},
async search() {
const q = this.query.trim();
if (q.length < 2) { this.results = []; this.open = false; return; }
this.loading = true;
this.open = true;
this.active = 0;
if (this._abort) this._abort.abort();
this._abort = new AbortController();
try {
const res = await fetch(`${this.searchUrl}?q=${encodeURIComponent(q)}`, {
signal: this._abort.signal,
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await res.json();
this.results = data.results || [];
} catch (e) {
if (e.name !== 'AbortError') this.results = [];
} finally {
this.loading = false;
}
},
moveDown() { if (this.active < this.results.length - 1) this.active++; },
moveUp() { if (this.active > 0) this.active--; },
go() {
if (this.results[this.active]) window.location.href = this.results[this.active].url;
},
}));
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 = {}) => ({
fileItems: [],
submitting: 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) {
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) {
if (this.prepared) {
return;
}
event.preventDefault();
const form = event.target;
const fileInput = form.querySelector('#files');
if (!this.fileItems.length) {
window.alert('Add at least one file to share.');
return;
}
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();
},
}));
window.Alpine = Alpine;
registerLadillConfirmStore(Alpine);
Alpine.start();