Files
ladill-transfer/resources/js/app.js
T
isaaccladandCursor f9b16f6b61
Deploy Ladill Transfer / deploy (push) Successful in 44s
Add Files page upload flow with manual folders and duplicate handling.
Users can create folders, upload files or folders, and choose replace or keep both on name conflicts instead of auto-grouping by transfer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:27:39 +00:00

769 lines
24 KiB
JavaScript

import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { 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,
_ignoreInputChange: false,
_uploadQueue: [],
_draining: false,
_drainPromise: null,
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) {
if (this._ignoreInputChange) {
return;
}
const picked = Array.from(event.target.files || []);
if (!picked.length) {
return;
}
this.prepared = false;
const existingKeys = new Set(this.fileItems.map((item) => item.key));
for (const file of picked) {
const key = `${file.name}:${file.size}:${file.lastModified}`;
if (existingKeys.has(key)) {
continue;
}
const item = {
id: (crypto.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random()}`,
key,
file,
name: file.name,
size: file.size,
status: 'pending',
progress: 0,
uploadId: null,
error: null,
};
this.fileItems.push(item);
this._uploadQueue.push(item);
}
this.resetFileInput(event.target);
void this.drainUploadQueue();
},
resetFileInput(input) {
this._ignoreInputChange = true;
input.value = '';
this._ignoreInputChange = false;
},
drainUploadQueue() {
if (this._drainPromise) {
return this._drainPromise;
}
this._drainPromise = (async () => {
this._draining = true;
while (this._uploadQueue.length) {
const item = this._uploadQueue.shift();
if (!item || item.status !== 'pending') {
continue;
}
await this.uploadFileItem(item);
}
this._draining = false;
this._drainPromise = null;
if (this._uploadQueue.length) {
void this.drainUploadQueue();
}
})();
return this._drainPromise;
},
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._uploadQueue = this._uploadQueue.filter((entry) => entry.id !== id);
},
async submit(event) {
if (this.prepared) {
return;
}
event.preventDefault();
const form = event.target;
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.drainUploadQueue();
}
if (this.fileItems.some((item) => item.status === 'uploading' || item.status === 'pending')) {
window.alert('Please wait for uploads to finish.');
return;
}
const readyItems = this.fileItems.filter((item) => item.status === 'ready' && item.uploadId);
if (!readyItems.length) {
window.alert('Add at least one file to share.');
return;
}
form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove());
for (const item of readyItems) {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'upload_ids[]';
hidden.value = item.uploadId;
form.appendChild(hidden);
}
this.submitting = true;
this.prepared = true;
form.submit();
},
}));
Alpine.data('filesManager', (config = {}) => ({
selected: [],
fileIds: config.fileIds || [],
files: config.files || [],
routes: config.routes || {},
csrf: config.csrf || '',
currentFolderId: config.currentFolderId || null,
moveModalOpen: false,
toast: '',
_toastTimer: null,
get allSelected() {
return this.fileIds.length > 0 && this.selected.length === this.fileIds.length;
},
toggleAll(event) {
this.selected = event.target.checked ? [...this.fileIds] : [];
},
clearSelection() {
this.selected = [];
},
selectedFiles() {
return this.files.filter((file) => this.selected.includes(file.id));
},
showToast(message) {
this.toast = message;
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => { this.toast = ''; }, 2800);
},
submitAction(url, extra = {}) {
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.style.display = 'none';
const csrf = document.createElement('input');
csrf.type = 'hidden';
csrf.name = '_token';
csrf.value = this.csrf;
form.appendChild(csrf);
this.selected.forEach((id) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'files[]';
input.value = id;
form.appendChild(input);
});
Object.entries(extra).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
},
downloadSelected() {
if (this.selected.length === 0) return;
if (this.selected.length === 1) {
const file = this.selectedFiles()[0];
if (file?.downloadUrl) {
window.location.href = file.downloadUrl;
}
return;
}
this.submitAction(this.routes.bulkDownload);
},
async deleteSelected() {
if (this.selected.length === 0) return;
if (! confirm(`Delete ${this.selected.length} file${this.selected.length === 1 ? '' : 's'}? This cannot be undone.`)) {
return;
}
this.submitAction(this.routes.bulkDelete);
},
async shareSelected() {
if (this.selected.length === 0) return;
try {
const res = await fetch(this.routes.share, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ files: this.selected }),
});
const data = await res.json();
if (data.links?.length) {
await navigator.clipboard.writeText(data.links.join('\n'));
this.showToast(data.message || 'Share links copied.');
}
} catch (e) {
this.showToast('Could not copy share links.');
}
},
async copyLinks() {
const links = [...new Set(this.selectedFiles().map((f) => f.shareUrl).filter(Boolean))];
if (links.length === 0) {
this.showToast('No share links available.');
return;
}
try {
await navigator.clipboard.writeText(links.join('\n'));
this.showToast(links.length === 1 ? 'Link copied.' : `${links.length} links copied.`);
} catch (e) {
this.showToast('Could not copy links.');
}
},
openInEmail() {
if (this.selected.length === 0) return;
const params = new URLSearchParams();
this.selected.forEach((id) => params.append('files[]', id));
window.location.href = `${this.routes.openInEmail}?${params.toString()}`;
},
createMenuOpen: false,
folderModalOpen: false,
newFolderName: '',
uploading: false,
duplicateModalOpen: false,
duplicateFile: null,
pendingUploads: null,
pendingResolutions: {},
duplicateQueue: [],
triggerFileUpload() {
this.createMenuOpen = false;
this.$refs.fileUploadInput?.click();
},
triggerFolderUpload() {
this.createMenuOpen = false;
this.$refs.folderUploadInput?.click();
},
openFolderModal() {
this.createMenuOpen = false;
this.folderModalOpen = true;
this.newFolderName = '';
this.$nextTick(() => this.$refs.folderNameInput?.focus());
},
async handleUploadPick(event) {
const picked = Array.from(event.target.files || []);
event.target.value = '';
if (picked.length === 0) return;
await this.processUploadQueue(picked);
},
async processUploadQueue(files) {
this.uploading = true;
try {
const checkRes = await fetch(this.routes.uploadCheck, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': this.csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
filenames: files.map((f) => f.name),
folder: this.currentFolderId || null,
}),
});
const checkData = await checkRes.json();
this.pendingUploads = files;
this.pendingResolutions = {};
this.duplicateQueue = checkData.conflicts || [];
if (this.duplicateQueue.length > 0) {
this.showNextDuplicate();
return;
}
await this.submitUpload(files, {});
} catch (e) {
this.showToast('Upload failed. Please try again.');
} finally {
if (!this.duplicateModalOpen) {
this.uploading = false;
}
}
},
showNextDuplicate() {
if (this.duplicateQueue.length === 0) {
this.duplicateModalOpen = false;
this.submitUpload(this.pendingUploads, this.pendingResolutions).finally(() => {
this.uploading = false;
this.pendingUploads = null;
this.pendingResolutions = {};
});
return;
}
this.duplicateFile = this.duplicateQueue.shift();
this.duplicateModalOpen = true;
},
resolveDuplicate(action) {
if (!this.duplicateFile) return;
this.pendingResolutions[this.duplicateFile.filename] = action;
this.duplicateModalOpen = false;
this.duplicateFile = null;
this.showNextDuplicate();
},
async submitUpload(files, resolutions) {
const formData = new FormData();
files.forEach((file) => formData.append('files[]', file));
if (this.currentFolderId) {
formData.append('folder', this.currentFolderId);
}
Object.entries(resolutions).forEach(([name, action]) => {
formData.append(`resolutions[${name}]`, action);
});
const res = await fetch(this.routes.upload, {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': this.csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.showToast(data.message || 'Upload failed.');
return;
}
this.showToast(data.message || 'Upload complete.');
window.location.reload();
},
}));
window.Alpine = Alpine;
registerLadillConfirmStore(Alpine);
Alpine.start();