Files
ladill-servers/resources/js/app.js
T
isaaccladandCursor fc58c9d40d
Deploy Ladill Servers / deploy (push) Successful in 29s
Add a mobile search page for Servers.
Return an HTML search screen from /search for browser visits so the bottom nav Search tab opens a usable lookup UI instead of raw JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 15:38:38 +00:00

551 lines
18 KiB
JavaScript

import './bootstrap';
import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';
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();
},
}));
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;
},
}));
window.serverOrderForm = (products = {}, billingCycleLabels = {}, initial = {}) => ({
products,
billingCycleLabels,
steps: [{ label: 'Select Plan' }, { label: 'Configure' }, { label: 'Review' }],
step: Number(initial.step ?? 1),
selectedProductId: initial.selectedProductId ? String(initial.selectedProductId) : '',
selectedProduct: null,
serverName: String(initial.serverName ?? ''),
billingCycle: String(initial.billingCycle ?? 'monthly'),
selections: {
region: '',
image: '',
license: '',
application: '',
default_user: '',
additional_ip: '',
private_networking: '',
storage_type: '',
object_storage: '',
},
serverPassword: '',
serverPasswordConfirmation: '',
submitting: false,
error: '',
clone(value) {
return JSON.parse(JSON.stringify(value ?? null));
},
init() {
if (this.selectedProductId) {
this.applyProduct(this.selectedProductId, true);
}
},
applyProduct(productId, preserveState = false) {
const product = this.products[String(productId)] || null;
if (!product) {
return;
}
this.selectedProductId = String(productId);
this.selectedProduct = this.clone(product);
const defaults = this.clone(product.defaults || {});
const initialSelections = preserveState ? this.clone(initial.selections || {}) : {};
this.selections = {
...defaults,
...initialSelections,
};
if (!preserveState) {
this.serverName = '';
this.billingCycle = 'monthly';
this.serverPassword = '';
this.serverPasswordConfirmation = '';
}
if (!Object.prototype.hasOwnProperty.call(this.billingCycleLabels, this.billingCycle)) {
this.billingCycle = 'monthly';
}
this.syncDefaultUser();
this.syncLicenseSelection();
this.error = '';
},
selectProduct(productId) {
this.applyProduct(productId, false);
},
nextStep() {
if (this.step === 1 && !this.selectedProductId) {
return;
}
if (this.step === 2 && !this.serverName.trim()) {
return;
}
if (this.step < this.steps.length) {
this.step += 1;
}
},
catalog(key) {
return this.selectedProduct?.catalog?.[key] || [];
},
findOption(options, value) {
return (options || []).find((option) => option.value === value) || null;
},
optionAmount(option) {
return Number(option?.prices?.[this.billingCycle] || 0);
},
optionLabel(option) {
if (!option) {
return '';
}
const amount = this.optionAmount(option);
return amount > 0
? `${option.label} (+${this.formatMoney(amount)})`
: option.label;
},
selectionLabel(catalogKey, value) {
const option = this.findOption(this.catalog(catalogKey), value);
return option?.label || value || '-';
},
currentImage() {
return this.findOption(this.catalog('images'), this.selections.image);
},
imageSupportsManagedStack(image = null) {
const currentImage = image || this.currentImage();
const osFamily = String(currentImage?.os_family || this.selectedProduct?.catalog?.default_image_os_family || 'linux').toLowerCase();
const imageLabel = String(currentImage?.label || '').toLowerCase();
const installLater = Boolean(currentImage?.is_install_later);
if (installLater || osFamily.startsWith('win')) {
return false;
}
if (['cpanel', 'plesk', 'whm'].some((needle) => imageLabel.includes(needle))) {
return false;
}
return ['ubuntu 22.04', 'ubuntu 24.04', 'debian 12', 'bookworm', 'almalinux', 'alma linux', 'alma', 'rocky linux', 'rocky', 'fedora', 'opensuse leap', 'open suse leap', 'opensuse', 'leap', 'centos']
.some((needle) => imageLabel.includes(needle));
},
bundledPanelFromImage(image = null) {
const imageLabel = String((image || this.currentImage())?.label || '').toLowerCase();
if (imageLabel.includes('cpanel') || imageLabel.includes('whm')) {
return 'cpanel';
}
if (imageLabel.includes('plesk')) {
return 'plesk';
}
return null;
},
availableLicenseOptions() {
const image = this.currentImage();
const installLater = Boolean(image?.is_install_later);
const bundledPanel = this.bundledPanelFromImage(image);
return this.catalog('licenses').filter((option) => {
if (installLater) {
return option.value === 'none';
}
if (bundledPanel && option.value === 'none') {
return false;
}
const optionLicense = String(option.license || '').toLowerCase();
if (bundledPanel === 'cpanel' && optionLicense.startsWith('plesk')) {
return false;
}
if (bundledPanel === 'plesk' && optionLicense.startsWith('cpanel')) {
return false;
}
if (option.panel !== 'ladill') {
return true;
}
return this.imageSupportsManagedStack(image);
});
},
currentLicenseOption() {
return this.findOption(this.availableLicenseOptions(), this.selections.license)
|| this.findOption(this.catalog('licenses'), this.selections.license);
},
currentPanelSnapshot() {
const license = this.currentLicenseOption();
const image = this.currentImage();
const managedCandidate = this.imageSupportsManagedStack(image);
if (license?.panel === 'ladill') {
return {
label: license.label || 'Ladill Server Manager',
primary: managedCandidate
? 'Ladill Server Manager will provision this server with Ladill-managed controls on the selected supported Linux image.'
: 'Ladill Server Manager is not available for the selected image.',
secondary: managedCandidate
? 'After provisioning finishes, Server Manager can handle power, status, files, databases, domains, PHP, SSL, cron, and logs.'
: 'Choose a plain supported Linux image without cPanel or Plesk to use Ladill-managed hosting features.',
futureCapabilities: managedCandidate ? ['Files', 'Databases', 'Domains', 'PHP', 'SSL', 'Cron', 'Logs'] : [],
toneClasses: managedCandidate
? 'border-amber-200 bg-amber-50 text-amber-900'
: 'border-slate-200 bg-slate-50 text-slate-700',
};
}
if (license?.license) {
return {
label: license.label || 'Server Panel',
primary: `${license.label} will run inside the server as the primary control panel.`,
secondary: 'Ladill will keep the order and infrastructure lifecycle, but site and service management will happen in the installed panel.',
futureCapabilities: [],
toneClasses: 'border-blue-200 bg-blue-50 text-blue-900',
};
}
return {
label: license?.label || 'Remote Login Only',
primary: 'This selection provisions the server without a Ladill-managed or commercial control panel.',
secondary: 'You will manage the machine directly over SSH or RDP until a panel or managed runtime is installed separately.',
futureCapabilities: [],
toneClasses: 'border-gray-200 bg-gray-50 text-gray-700',
};
},
defaultUserOptions() {
const defaultFamily = this.selectedProduct?.catalog?.default_image_os_family || 'linux';
const osFamily = this.currentImage()?.os_family || defaultFamily;
return osFamily.startsWith('win')
? this.catalog('windows_default_users')
: this.catalog('linux_default_users');
},
syncDefaultUser() {
const options = this.defaultUserOptions();
if (!options.some((option) => option.value === this.selections.default_user)) {
this.selections.default_user = options[0]?.value || '';
}
},
syncLicenseSelection() {
const options = this.availableLicenseOptions();
if (!options.some((option) => option.value === this.selections.license)) {
this.selections.license = options[0]?.value || 'none';
}
},
handleImageChange() {
this.syncDefaultUser();
this.syncLicenseSelection();
},
basePrice() {
return Number(this.selectedProduct?.prices?.[this.billingCycle] || 0);
},
setupFee() {
return Number(this.selectedProduct?.setup_fees?.[this.billingCycle] || 0);
},
addOnLineItems() {
const items = [];
const setupFee = this.setupFee();
if (setupFee > 0) {
items.push({
key: 'setup_fee',
label: 'One-time setup fee',
amount: setupFee,
});
}
const keys = ['image', 'region', 'license', 'application', 'additional_ip', 'private_networking', 'storage_type', 'object_storage'];
const catalogKeys = {
image: 'images',
region: 'regions',
license: 'licenses',
application: 'applications',
additional_ip: 'additional_ip',
private_networking: 'private_networking',
storage_type: 'storage_types',
object_storage: 'object_storage',
};
keys.forEach((key) => {
const option = this.findOption(this.catalog(catalogKeys[key]), this.selections[key]);
const amount = this.optionAmount(option);
if (amount > 0) {
items.push({
key,
label: option.label,
amount,
});
}
});
return items;
},
currentTotal() {
return this.basePrice() + this.addOnLineItems().reduce((sum, item) => sum + item.amount, 0);
},
cycleMonths() {
return {
monthly: 1,
quarterly: 3,
semiannual: 6,
yearly: 12,
}[this.billingCycle] || 1;
},
monthlyEquivalent() {
return this.currentTotal() / this.cycleMonths();
},
formatMoney(amount, currency = null) {
if (amount === null || amount === undefined) {
return '-';
}
const code = currency || this.selectedProduct?.currency || 'GHS';
return `${code} ${Number(amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
},
canSubmit() {
return Boolean(
this.selectedProductId
&& this.serverName.trim()
&& this.serverPassword
&& this.serverPassword === this.serverPasswordConfirmation
&& this.serverPassword.length >= 8
);
},
submitOrder() {
if (!this.canSubmit()) {
this.error = 'Complete the required fields and make sure the passwords match.';
return;
}
this.submitting = true;
this.error = '';
this.$refs.form.submit();
},
});
window.Alpine = Alpine;
Alpine.start();