A modern web interface for Luanti (Minetest) server management with ContentDB integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
496 lines
14 KiB
JavaScript
496 lines
14 KiB
JavaScript
// Main JavaScript for Luanti Web Server
|
|
class LuantiWebServer {
|
|
constructor() {
|
|
this.socket = null;
|
|
this.serverStatus = 'stopped';
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
// Initialize Socket.IO
|
|
this.initSocket();
|
|
|
|
// Initialize UI components
|
|
this.initUI();
|
|
|
|
// Initialize forms
|
|
this.initForms();
|
|
|
|
// Initialize real-time updates
|
|
this.initRealTime();
|
|
}
|
|
|
|
initSocket() {
|
|
this.socket = io();
|
|
|
|
this.socket.on('connect', () => {
|
|
console.log('Connected to server');
|
|
this.updateConnectionStatus(true);
|
|
});
|
|
|
|
this.socket.on('disconnect', () => {
|
|
console.log('Disconnected from server');
|
|
this.updateConnectionStatus(false);
|
|
});
|
|
|
|
this.socket.on('serverStatus', (status) => {
|
|
this.updateServerStatus(status);
|
|
});
|
|
|
|
this.socket.on('serverLog', (logEntry) => {
|
|
this.appendLogEntry(logEntry);
|
|
});
|
|
}
|
|
|
|
initUI() {
|
|
// Modal functionality
|
|
this.initModals();
|
|
|
|
// Tooltips
|
|
this.initTooltips();
|
|
|
|
// Auto-refresh toggles
|
|
this.initAutoRefresh();
|
|
}
|
|
|
|
initModals() {
|
|
// Generic modal handling
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.matches('[data-modal-open]')) {
|
|
const modalId = e.target.getAttribute('data-modal-open');
|
|
this.openModal(modalId);
|
|
}
|
|
|
|
if (e.target.matches('[data-modal-close]') || e.target.closest('[data-modal-close]')) {
|
|
this.closeModal();
|
|
}
|
|
|
|
if (e.target.matches('.modal')) {
|
|
this.closeModal();
|
|
}
|
|
});
|
|
|
|
// Escape key to close modal
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
this.closeModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
openModal(modalId) {
|
|
const modal = document.getElementById(modalId);
|
|
if (modal) {
|
|
modal.style.display = 'flex';
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
// Focus first input in modal
|
|
const firstInput = modal.querySelector('input, textarea, select');
|
|
if (firstInput) {
|
|
setTimeout(() => firstInput.focus(), 100);
|
|
}
|
|
}
|
|
}
|
|
|
|
closeModal() {
|
|
const modals = document.querySelectorAll('.modal');
|
|
modals.forEach(modal => {
|
|
modal.style.display = 'none';
|
|
});
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
initTooltips() {
|
|
// Simple tooltip implementation
|
|
document.querySelectorAll('[data-tooltip]').forEach(element => {
|
|
element.addEventListener('mouseenter', (e) => {
|
|
this.showTooltip(e.target, e.target.getAttribute('data-tooltip'));
|
|
});
|
|
|
|
element.addEventListener('mouseleave', () => {
|
|
this.hideTooltip();
|
|
});
|
|
});
|
|
}
|
|
|
|
showTooltip(element, text) {
|
|
const tooltip = document.createElement('div');
|
|
tooltip.className = 'tooltip';
|
|
tooltip.textContent = text;
|
|
tooltip.style.cssText = `
|
|
position: absolute;
|
|
background: #1f2937;
|
|
color: white;
|
|
padding: 0.5rem;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
z-index: 1000;
|
|
white-space: nowrap;
|
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
`;
|
|
|
|
document.body.appendChild(tooltip);
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
tooltip.style.top = rect.top - tooltip.offsetHeight - 8 + 'px';
|
|
tooltip.style.left = rect.left + (rect.width - tooltip.offsetWidth) / 2 + 'px';
|
|
}
|
|
|
|
hideTooltip() {
|
|
const tooltip = document.querySelector('.tooltip');
|
|
if (tooltip) {
|
|
tooltip.remove();
|
|
}
|
|
}
|
|
|
|
initAutoRefresh() {
|
|
const autoRefreshElements = document.querySelectorAll('[data-auto-refresh]');
|
|
autoRefreshElements.forEach(element => {
|
|
const interval = parseInt(element.getAttribute('data-auto-refresh')) || 5000;
|
|
const url = element.getAttribute('data-refresh-url') || window.location.href;
|
|
|
|
setInterval(async () => {
|
|
if (element.checked || element.getAttribute('data-auto-refresh-active') === 'true') {
|
|
await this.refreshElement(element, url);
|
|
}
|
|
}, interval);
|
|
});
|
|
}
|
|
|
|
async refreshElement(element, url) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
// This would need specific implementation per element type
|
|
console.log('Auto-refresh triggered for', element);
|
|
}
|
|
} catch (error) {
|
|
console.error('Auto-refresh failed:', error);
|
|
}
|
|
}
|
|
|
|
initForms() {
|
|
// AJAX form submission
|
|
document.addEventListener('submit', async (e) => {
|
|
if (e.target.matches('[data-ajax-form]')) {
|
|
e.preventDefault();
|
|
await this.submitAjaxForm(e.target);
|
|
}
|
|
});
|
|
|
|
// Real-time form validation
|
|
this.initFormValidation();
|
|
}
|
|
|
|
async submitAjaxForm(form) {
|
|
const submitBtn = form.querySelector('[type="submit"]');
|
|
const originalText = submitBtn.textContent;
|
|
|
|
try {
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Processing...';
|
|
|
|
const formData = new FormData(form);
|
|
const response = await fetch(form.action, {
|
|
method: form.method || 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
if (response.headers.get('content-type')?.includes('application/json')) {
|
|
const result = await response.json();
|
|
this.showAlert(result.message || 'Success', 'success');
|
|
} else {
|
|
// Handle redirect or page reload
|
|
window.location.reload();
|
|
}
|
|
} else {
|
|
const error = await response.text();
|
|
this.showAlert(error || 'An error occurred', 'danger');
|
|
}
|
|
} catch (error) {
|
|
this.showAlert('Network error: ' + error.message, 'danger');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
initFormValidation() {
|
|
// Real-time validation for world/mod names
|
|
document.addEventListener('input', (e) => {
|
|
if (e.target.matches('[data-validate-name]')) {
|
|
this.validateName(e.target);
|
|
}
|
|
});
|
|
}
|
|
|
|
validateName(input) {
|
|
const value = input.value;
|
|
const isValid = /^[a-zA-Z0-9_-]+$/.test(value) && value.length <= 50;
|
|
|
|
input.setCustomValidity(isValid ? '' : 'Only letters, numbers, underscore and hyphen allowed (max 50 chars)');
|
|
|
|
// Visual feedback
|
|
input.classList.toggle('is-invalid', !isValid && value.length > 0);
|
|
input.classList.toggle('is-valid', isValid && value.length > 0);
|
|
}
|
|
|
|
initRealTime() {
|
|
// Auto-scroll logs
|
|
this.initLogAutoScroll();
|
|
}
|
|
|
|
initLogAutoScroll() {
|
|
const logsContainer = document.querySelector('.logs');
|
|
if (logsContainer) {
|
|
// Auto-scroll to bottom when new logs arrive
|
|
const observer = new MutationObserver(() => {
|
|
if (logsContainer.scrollTop + logsContainer.clientHeight >= logsContainer.scrollHeight - 100) {
|
|
logsContainer.scrollTop = logsContainer.scrollHeight;
|
|
}
|
|
});
|
|
|
|
observer.observe(logsContainer, { childList: true, subtree: true });
|
|
}
|
|
}
|
|
|
|
updateConnectionStatus(connected) {
|
|
const statusElement = document.getElementById('connection-status');
|
|
if (statusElement) {
|
|
statusElement.className = connected ? 'status status-running' : 'status status-error';
|
|
statusElement.textContent = connected ? 'Connected' : 'Disconnected';
|
|
}
|
|
}
|
|
|
|
updateServerStatus(status) {
|
|
this.serverStatus = status.status;
|
|
|
|
// Update status badge
|
|
const statusElement = document.getElementById('server-status');
|
|
if (statusElement) {
|
|
statusElement.className = `status status-${status.status}`;
|
|
statusElement.textContent = status.status.charAt(0).toUpperCase() + status.status.slice(1);
|
|
}
|
|
|
|
// Update PID
|
|
const pidElement = document.getElementById('server-pid');
|
|
if (pidElement) {
|
|
pidElement.textContent = status.pid || 'N/A';
|
|
}
|
|
|
|
// Update uptime
|
|
const uptimeElement = document.getElementById('server-uptime');
|
|
if (uptimeElement) {
|
|
uptimeElement.textContent = this.formatUptime(status.uptime);
|
|
}
|
|
|
|
// Update control buttons
|
|
this.updateServerControls(status.status);
|
|
}
|
|
|
|
updateServerControls(status) {
|
|
const startBtn = document.getElementById('server-start');
|
|
const stopBtn = document.getElementById('server-stop');
|
|
const restartBtn = document.getElementById('server-restart');
|
|
|
|
if (startBtn) startBtn.disabled = status === 'running';
|
|
if (stopBtn) stopBtn.disabled = status === 'stopped';
|
|
if (restartBtn) restartBtn.disabled = status === 'stopped';
|
|
}
|
|
|
|
appendLogEntry(logEntry) {
|
|
const logsContainer = document.querySelector('.logs');
|
|
if (!logsContainer) return;
|
|
|
|
const logElement = document.createElement('div');
|
|
logElement.className = 'log-entry';
|
|
|
|
if (typeof logEntry === 'string') {
|
|
logElement.textContent = logEntry;
|
|
} else {
|
|
logElement.innerHTML = `
|
|
<span class="log-timestamp">[${new Date(logEntry.timestamp).toLocaleTimeString()}]</span>
|
|
<span class="log-level-${logEntry.level}">${logEntry.message}</span>
|
|
`;
|
|
}
|
|
|
|
logsContainer.appendChild(logElement);
|
|
|
|
// Limit log entries to prevent memory issues
|
|
const logEntries = logsContainer.children;
|
|
if (logEntries.length > 1000) {
|
|
logEntries[0].remove();
|
|
}
|
|
}
|
|
|
|
formatUptime(uptime) {
|
|
if (!uptime) return 'N/A';
|
|
const seconds = Math.floor(uptime / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
|
} else if (minutes > 0) {
|
|
return `${minutes}m ${seconds % 60}s`;
|
|
} else {
|
|
return `${seconds}s`;
|
|
}
|
|
}
|
|
|
|
showAlert(message, type = 'info', duration = 5000) {
|
|
const alertsContainer = document.getElementById('alerts') || this.createAlertsContainer();
|
|
|
|
const alert = document.createElement('div');
|
|
alert.className = `alert alert-${type}`;
|
|
alert.innerHTML = `
|
|
<span>${message}</span>
|
|
<button type="button" class="modal-close" style="margin-left: auto;">×</button>
|
|
`;
|
|
|
|
alertsContainer.appendChild(alert);
|
|
|
|
// Auto-remove alert
|
|
setTimeout(() => {
|
|
if (alert.parentNode) {
|
|
alert.remove();
|
|
}
|
|
}, duration);
|
|
|
|
// Manual close
|
|
alert.querySelector('.modal-close').addEventListener('click', () => {
|
|
alert.remove();
|
|
});
|
|
}
|
|
|
|
createAlertsContainer() {
|
|
const container = document.createElement('div');
|
|
container.id = 'alerts';
|
|
container.style.cssText = `
|
|
position: fixed;
|
|
top: 1rem;
|
|
right: 1rem;
|
|
z-index: 1000;
|
|
max-width: 400px;
|
|
`;
|
|
document.body.appendChild(container);
|
|
return container;
|
|
}
|
|
|
|
// Utility methods
|
|
async api(endpoint, options = {}) {
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options.headers
|
|
},
|
|
...options
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('application/json')) {
|
|
return await response.json();
|
|
} else {
|
|
return await response.text();
|
|
}
|
|
} catch (error) {
|
|
console.error('API Error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
formatDate(date) {
|
|
return new Date(date).toLocaleString();
|
|
}
|
|
|
|
debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
}
|
|
|
|
// Initialize when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.luantiWebServer = new LuantiWebServer();
|
|
});
|
|
|
|
// Global utility functions
|
|
window.confirmDelete = function(itemType, itemName) {
|
|
console.log(`confirmDelete called with: itemType="${itemType}", itemName="${itemName}"`);
|
|
|
|
if (itemType === 'world') {
|
|
// Extra confirmation for world deletion - require typing the world name
|
|
const message = `WARNING: You are about to permanently delete the world "${itemName}".\n\n` +
|
|
`This will remove ALL world data including:\n` +
|
|
`• All builds and constructions\n` +
|
|
`• Player inventories and progress\n` +
|
|
`• World settings and configuration\n` +
|
|
`• All world-specific mods and data\n\n` +
|
|
`This action cannot be undone!\n\n` +
|
|
`Type the world name exactly to confirm:`;
|
|
|
|
const confirmation = prompt(message);
|
|
console.log(`User entered: "${confirmation}", expected: "${itemName}"`);
|
|
|
|
if (confirmation === null) {
|
|
console.log('User cancelled the dialog');
|
|
return false;
|
|
}
|
|
|
|
const matches = confirmation === itemName;
|
|
console.log(`Confirmation ${matches ? 'matches' : 'does not match'}`);
|
|
|
|
if (!matches && confirmation !== null) {
|
|
alert('Deletion cancelled - world name did not match exactly.');
|
|
}
|
|
|
|
return matches;
|
|
} else {
|
|
// Standard confirmation for other items
|
|
return confirm(`Are you sure you want to delete the ${itemType} "${itemName}"? This action cannot be undone.`);
|
|
}
|
|
};
|
|
|
|
window.showLoading = function(element, text = 'Loading...') {
|
|
if (typeof element === 'string') {
|
|
element = document.querySelector(element);
|
|
}
|
|
if (element) {
|
|
element.innerHTML = `
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<div>${text}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
};
|
|
|
|
window.hideLoading = function(element) {
|
|
if (typeof element === 'string') {
|
|
element = document.querySelector(element);
|
|
}
|
|
if (element) {
|
|
element.innerHTML = '';
|
|
}
|
|
}; |