Deploy Ladill Transfer / deploy (push) Successful in 1m41s
Use the shared Inline sheet, hold busy until payment UI opens, and avoid locking page scroll during the handoff.
986 lines
31 KiB
JavaScript
986 lines
31 KiB
JavaScript
import './bootstrap';
|
|
|
|
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
|
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
|
import { uploadFileChunked } from './chunked-upload';
|
|
|
|
registerLadillModalHelpers();
|
|
registerLadillSearchShortcut();
|
|
|
|
|
|
import Alpine from 'alpinejs';
|
|
import { registerLadillClipboard } from './ladill-clipboard';
|
|
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);
|
|
registerLadillClipboard(Alpine);
|
|
|
|
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: '',
|
|
accessCode: '',
|
|
returnUrl: '',
|
|
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;
|
|
}
|
|
this.checkoutUrl = data.checkout_url || data.authorization_url || '';
|
|
this.accessCode = data.access_code || '';
|
|
this.returnUrl = data.callback_url || this.returnUrl || '';
|
|
window.LadillPayCheckout?.prepare?.();
|
|
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
|
|
} catch (e) {
|
|
window.LadillPayCheckout?.cancel?.();
|
|
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`;
|
|
}
|
|
|
|
function uploadFileBasename(file) {
|
|
const relative = file.webkitRelativePath || '';
|
|
if (relative.includes('/')) {
|
|
return relative.split('/').pop() || file.name;
|
|
}
|
|
|
|
return (file.name.split(/[/\\]/).pop() || file.name);
|
|
}
|
|
|
|
function flattenFolderUploadFiles(files) {
|
|
const usedNames = new Set();
|
|
|
|
return files.map((file) => {
|
|
let name = uploadFileBasename(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,
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
},
|
|
|
|
queueFilesForUpload(picked) {
|
|
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);
|
|
existingKeys.add(key);
|
|
}
|
|
|
|
void this.drainUploadQueue();
|
|
},
|
|
|
|
onFilesChange(event) {
|
|
if (this._ignoreInputChange) {
|
|
return;
|
|
}
|
|
|
|
const picked = Array.from(event.target.files || []);
|
|
this.resetFileInput(event.target);
|
|
this.queueFilesForUpload(picked);
|
|
},
|
|
|
|
onFolderChange(event) {
|
|
if (this._ignoreInputChange) {
|
|
return;
|
|
}
|
|
|
|
const picked = flattenFolderUploadFiles(Array.from(event.target.files || []));
|
|
this.resetFileInput(event.target);
|
|
this.queueFilesForUpload(picked);
|
|
},
|
|
|
|
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 = {}) => ({
|
|
selectedFileIds: [],
|
|
selectedFolderIds: [],
|
|
fileIds: config.fileIds || [],
|
|
files: config.files || [],
|
|
folderIds: config.folderIds || [],
|
|
folders: config.folders || [],
|
|
routes: config.routes || {},
|
|
csrf: config.csrf || '',
|
|
currentFolderId: config.currentFolderId || null,
|
|
moveModalOpen: false,
|
|
toast: '',
|
|
_toastTimer: null,
|
|
|
|
get selectedCount() {
|
|
return this.selectedFileIds.length + this.selectedFolderIds.length;
|
|
},
|
|
|
|
get allSelected() {
|
|
const total = this.fileIds.length + this.folderIds.length;
|
|
return total > 0
|
|
&& this.selectedFileIds.length === this.fileIds.length
|
|
&& this.selectedFolderIds.length === this.folderIds.length;
|
|
},
|
|
|
|
toggleAll(event) {
|
|
if (event.target.checked) {
|
|
this.selectedFileIds = [...this.fileIds];
|
|
this.selectedFolderIds = [...this.folderIds];
|
|
} else {
|
|
this.selectedFileIds = [];
|
|
this.selectedFolderIds = [];
|
|
}
|
|
},
|
|
|
|
clearSelection() {
|
|
this.selectedFileIds = [];
|
|
this.selectedFolderIds = [];
|
|
},
|
|
|
|
selectedFileRows() {
|
|
return this.files.filter((file) => this.selectedFileIds.includes(file.id));
|
|
},
|
|
|
|
selectedFolderRows() {
|
|
return this.folders.filter((folder) => this.selectedFolderIds.includes(folder.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.selectedFileIds.forEach((id) => {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'files[]';
|
|
input.value = id;
|
|
form.appendChild(input);
|
|
});
|
|
|
|
this.selectedFolderIds.forEach((id) => {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'folders[]';
|
|
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.selectedCount === 0) return;
|
|
|
|
if (this.selectedFileIds.length === 1 && this.selectedFolderIds.length === 0) {
|
|
const file = this.selectedFileRows()[0];
|
|
if (file?.downloadUrl) {
|
|
window.location.href = file.downloadUrl;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.selectedFolderIds.length === 1 && this.selectedFileIds.length === 0) {
|
|
const folder = this.selectedFolderRows()[0];
|
|
if (folder?.downloadUrl) {
|
|
window.location.href = folder.downloadUrl;
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.submitAction(this.routes.bulkDownload);
|
|
},
|
|
|
|
async deleteSelected() {
|
|
if (this.selectedFileIds.length === 0) return;
|
|
if (this.selectedFolderIds.length > 0) {
|
|
this.showToast('Delete files individually. Folder delete is not supported yet.');
|
|
return;
|
|
}
|
|
const count = this.selectedFileIds.length;
|
|
const confirmed = await Alpine.store('ladillConfirm').ask({
|
|
title: count === 1 ? 'Delete file?' : `Delete ${count} files?`,
|
|
message: 'This cannot be undone.',
|
|
confirmLabel: 'Delete',
|
|
cancelLabel: 'Cancel',
|
|
variant: 'danger',
|
|
});
|
|
if (!confirmed) return;
|
|
this.submitAction(this.routes.bulkDelete);
|
|
},
|
|
|
|
async shareSelected() {
|
|
if (this.selectedCount === 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.selectedFileIds,
|
|
folders: this.selectedFolderIds,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.links?.length) {
|
|
await navigator.clipboard.writeText(data.links.join('\n'));
|
|
this.showToast(data.message || 'Share links copied.');
|
|
} else {
|
|
this.showToast(data.message || 'No share links available.');
|
|
}
|
|
} catch (e) {
|
|
this.showToast('Could not copy share links.');
|
|
}
|
|
},
|
|
|
|
async copyLinks() {
|
|
const links = [
|
|
...this.selectedFileRows().map((f) => f.shareUrl),
|
|
...this.selectedFolderRows().map((f) => f.shareUrl),
|
|
].filter(Boolean);
|
|
const uniqueLinks = [...new Set(links)];
|
|
if (uniqueLinks.length === 0) {
|
|
this.showToast('No share links available.');
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(uniqueLinks.join('\n'));
|
|
this.showToast(uniqueLinks.length === 1 ? 'Link copied.' : `${uniqueLinks.length} links copied.`);
|
|
} catch (e) {
|
|
this.showToast('Could not copy links.');
|
|
}
|
|
},
|
|
|
|
openInEmail() {
|
|
if (this.selectedFileIds.length === 0) return;
|
|
if (this.selectedFolderIds.length > 0) {
|
|
this.showToast('Open in email works with files only.');
|
|
return;
|
|
}
|
|
const params = new URLSearchParams();
|
|
this.selectedFileIds.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;
|
|
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;
|
|
|
|
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) {
|
|
return uploadFileBasename(file);
|
|
},
|
|
|
|
flattenFolderUploadFiles(files) {
|
|
return flattenFolderUploadFiles(files);
|
|
},
|
|
|
|
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();
|
|
},
|
|
}));
|
|
|
|
|
|
// Wallet balance peek for the avatar dropdown.
|
|
Alpine.data('walletWidget', (config = {}) => ({
|
|
display: '…',
|
|
async load() {
|
|
if (! config.url) {
|
|
this.display = 'View wallet';
|
|
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
|
|
const data = await res.json();
|
|
this.display = data.available ? data.formatted : 'View wallet';
|
|
} catch (e) {
|
|
this.display = 'View wallet';
|
|
}
|
|
},
|
|
}));
|
|
|
|
window.Alpine = Alpine;
|
|
registerLadillConfirmStore(Alpine);
|
|
|
|
Alpine.start();
|