Files
ladill-transfer/resources/js/app.js
T
isaaccladandCursor 7bf92e2d2c
Deploy Ladill Transfer / deploy (push) Successful in 27s
Create a Ladill folder when uploading a local folder and place files inside it.
Folder upload now names and creates the destination folder from the picked directory, uploads all files into it, and redirects to the new folder view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 22:03:03 +00:00

919 lines
29 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: [],
pendingUploadFolderName: null,
uploadConfirmOpen: false,
pendingConfirmFiles: null,
uploadConfirmFolderName: '',
uploadConfirmIsFolder: false,
triggerFileUpload() {
this.createMenuOpen = false;
this.$refs.fileUploadInput?.click();
},
triggerFolderUpload() {
this.createMenuOpen = false;
if (typeof window.showDirectoryPicker === 'function') {
this.pickFolderWithApi();
return;
}
this.$refs.folderUploadInput?.click();
},
async pickFolderWithApi() {
try {
const handle = await window.showDirectoryPicker();
const files = await this.collectDirectoryFiles(handle);
if (files.length === 0) {
this.showToast('That folder is empty.');
return;
}
this.openUploadConfirm(files, handle.name, true);
} catch (error) {
if (error?.name !== 'AbortError') {
this.showToast('Could not read that folder.');
}
}
},
async collectDirectoryFiles(dirHandle, path = '') {
const files = [];
for await (const entry of dirHandle.values()) {
const entryPath = path ? `${path}/${entry.name}` : entry.name;
if (entry.kind === 'file') {
const file = await entry.getFile();
Object.defineProperty(file, 'webkitRelativePath', {
value: entryPath,
configurable: true,
});
files.push(file);
} else if (entry.kind === 'directory') {
files.push(...await this.collectDirectoryFiles(entry, entryPath));
}
}
return files;
},
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;
const isFolder = Boolean(picked[0]?.webkitRelativePath);
const folderName = isFolder
? (picked[0].webkitRelativePath.split('/')[0] || 'Selected folder')
: '';
this.openUploadConfirm(picked, folderName, isFolder);
},
openUploadConfirm(files, folderName = '', isFolder = false) {
this.pendingConfirmFiles = isFolder ? this.flattenFolderUploadFiles(files) : files;
this.uploadConfirmFolderName = folderName;
this.uploadConfirmIsFolder = isFolder;
this.uploadConfirmOpen = true;
},
fileBasename(file) {
const relative = file.webkitRelativePath || '';
if (relative.includes('/')) {
return relative.split('/').pop() || file.name;
}
return (file.name.split(/[/\\]/).pop() || file.name);
},
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,
});
});
},
cancelUploadConfirm() {
this.uploadConfirmOpen = false;
this.pendingConfirmFiles = null;
this.uploadConfirmFolderName = '';
this.uploadConfirmIsFolder = false;
},
confirmUpload() {
const files = this.pendingConfirmFiles;
const uploadFolderName = this.uploadConfirmIsFolder ? this.uploadConfirmFolderName : null;
this.cancelUploadConfirm();
if (files?.length) {
this.processUploadQueue(files, { uploadFolderName });
}
},
uploadConfirmTotalBytes() {
return (this.pendingConfirmFiles || []).reduce((sum, file) => sum + (file.size || 0), 0);
},
uploadConfirmPreviewNames() {
return (this.pendingConfirmFiles || []).slice(0, 5).map((file) => file.name);
},
uploadConfirmRemainingCount() {
const total = (this.pendingConfirmFiles || []).length;
return total > 5 ? total - 5 : 0;
},
formatBytes(bytes) {
return formatUploadBytes(bytes);
},
async processUploadQueue(files, options = {}) {
this.uploading = true;
this.pendingUploadFolderName = options.uploadFolderName || null;
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) => this.fileBasename(f)),
folder: this.pendingUploadFolderName ? null : (this.currentFolderId || null),
upload_folder_name: this.pendingUploadFolderName,
}),
});
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 = {};
this.pendingUploadFolderName = null;
});
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) => {
const name = this.fileBasename(file);
formData.append('files[]', file, name);
});
if (this.currentFolderId && !this.pendingUploadFolderName) {
formData.append('folder', this.currentFolderId);
}
if (this.pendingUploadFolderName) {
formData.append('upload_folder_name', this.pendingUploadFolderName);
}
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.');
if (data.folder_id && this.routes.filesIndex) {
window.location.href = `${this.routes.filesIndex}?folder=${data.folder_id}`;
return;
}
window.location.reload();
},
}));
window.Alpine = Alpine;
registerLadillConfirmStore(Alpine);
Alpine.start();