Initial commit: LuHost - Luanti Server Management Web Interface

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>
This commit is contained in:
Nathan Schneider
2025-08-23 17:32:37 -06:00
commit 3aed09b60f
47 changed files with 12878 additions and 0 deletions

693
views/extensions/index.ejs Normal file
View File

@@ -0,0 +1,693 @@
<%
const body = `
<div class="page-header">
<h2>🧩 Extensions</h2>
<p>Manage games, mods, and texture packs for your Luanti server</p>
</div>
<div class="extensions-layout">
<!-- Sidebar -->
<div class="extensions-sidebar">
<!-- Overview Card -->
<div class="card">
<div class="card-header">
<h4>📊 Overview</h4>
</div>
<div class="card-body">
<div class="overview-stats">
<div class="stat-item">
<strong>${statistics.games || 0}</strong>
<span>Games</span>
</div>
<div class="stat-item">
<strong>${(statistics.global_packages || 0) + (statistics.local_mods || 0)}</strong>
<span>Mods</span>
</div>
<div class="stat-item">
<strong>${statistics.total_packages || 0}</strong>
<span>Total</span>
</div>
</div>
</div>
</div>
<!-- Quick Install Card -->
<div class="card">
<div class="card-header">
<h4>⚡ Quick Install</h4>
</div>
<div class="card-body">
<form id="quickInstallForm">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group mb-3">
<label for="quickPackageUrl">Package URL or Author/Name:</label>
<input type="text" id="quickPackageUrl" name="packageUrl" class="form-control"
placeholder="e.g., mesecons or author/name" required>
<div id="quickUrlValidation"></div>
</div>
<div class="form-group mb-3" id="quickLocationGroup">
<label for="quickInstallLocation">Install Location:</label>
<select id="quickInstallLocation" name="installLocation" class="form-control">
<option value="global">Global</option>
<option value="world">Specific World</option>
</select>
<select id="quickWorldName" name="worldName" class="form-control mt-2" style="display: none;">
<option value="">Select a world...</option>
</select>
</div>
<div class="form-group mb-3">
<label>
<input type="checkbox" name="installDeps" value="on">
Install Dependencies
</label>
</div>
<button type="submit" id="quickInstallBtn" class="btn btn-primary btn-block">
📦 Install
</button>
<div id="quickInstallStatus" style="display: none;">
<div id="quickStatusAlert" class="alert mt-2">
<span id="quickStatusMessage"></span>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Main Content -->
<div class="extensions-main">
<div class="extensions-header">
<div class="extensions-tabs">
<button class="tab-btn active" onclick="filterExtensions('all')">
All (${allContent.length})
</button>
<button class="tab-btn" onclick="filterExtensions('game')">
Games (${allContent.filter(c => (c.package_type || c.type) === 'game').length})
</button>
<button class="tab-btn" onclick="filterExtensions('mod')">
Mods (${allContent.filter(c => (c.package_type || c.type) === 'mod').length})
</button>
<button class="tab-btn" onclick="filterExtensions('txp')">
Texture Packs (${allContent.filter(c => (c.package_type || c.type) === 'txp').length})
</button>
</div>
</div>
${allContent.length === 0 ? `
<div class="card">
<div class="card-body text-center">
<h3>📭 No Extensions Installed</h3>
<p>Install games, mods, and texture packs from ContentDB or add them manually.</p>
<a href="/contentdb" class="btn btn-primary">
Browse ContentDB
</a>
</div>
</div>
` : `
<div class="extensions-grid" id="extensionsGrid">
${allContent.map(ext => {
const type = ext.package_type || ext.type;
const typeIcon = type === 'game' ? '🎮' : type === 'txp' ? '🎨' : '📦';
const typeBadge = type === 'game' ? 'success' : type === 'txp' ? 'warning' : 'primary';
const sourceIcon = ext.source === 'contentdb' ? '🌐' : '📁';
return `
<div class="card extension-card" data-type="${type}">
<div class="card-header">
<div class="extension-title">
<h4>${typeIcon} ${ext.title || ext.name}</h4>
<small class="text-muted">
${sourceIcon} ${ext.author || 'Local'}
${ext.source === 'contentdb' ? '(ContentDB)' : '(Local)'}
</small>
</div>
<div class="extension-badges">
<span class="badge badge-${typeBadge}">
${type === 'txp' ? 'Texture Pack' : type.charAt(0).toUpperCase() + type.slice(1)}
</span>
</div>
</div>
<div class="card-body">
<div class="extension-details">
<p class="extension-description">
${ext.short_description || ext.description || 'No description available.'}
</p>
<div class="extension-meta">
<div class="meta-item">
<strong>Location:</strong>
<span class="location-badge ${ext.install_location === 'global' || ext.location === 'global' ? 'global' : 'world'}">
${ext.install_location === 'global' || ext.location === 'global' ? 'Global' :
ext.install_location ? ext.install_location.replace('world:', '') : ext.location || 'Games'}
</span>
</div>
${ext.version ? `
<div class="meta-item">
<strong>Version:</strong>
<span>${ext.version}</span>
</div>
` : ''}
<div class="meta-item">
<strong>Modified:</strong>
<span>${ext.installed_at ? new Date(ext.installed_at).toLocaleDateString() :
new Date(ext.lastModified).toLocaleDateString()}</span>
</div>
</div>
${ext.dependencies && ext.dependencies.length > 0 ? `
<div class="dependencies">
<strong>Dependencies (${ext.dependencies.length}):</strong>
<div class="dep-list">
${ext.dependencies.map(dep =>
typeof dep === 'string' ? dep : `${dep.author}/${dep.name}`
).join(', ')}
</div>
</div>
` : ''}
</div>
<div class="extension-actions">
${ext.contentdb_url ? `
<a href="${ext.contentdb_url}" target="_blank" class="btn btn-outline-primary btn-sm">
View on ContentDB
</a>
` : ''}
${ext.source === 'contentdb' ? `
<button class="btn btn-outline-warning btn-sm"
onclick="checkForUpdate('${ext.author}', '${ext.name}')">
Check Update
</button>
` : ''}
<button class="btn btn-outline-danger btn-sm"
onclick="uninstallExtension('${ext.name}', '${type}', '${ext.install_location || ext.location}')">
Remove
</button>
</div>
</div>
</div>
`;
}).join('')}
</div>
`}
</div>
</div>
<style>
.extensions-layout {
display: flex;
gap: 2rem;
}
.extensions-sidebar {
flex: 0 0 300px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.extensions-main {
flex: 1;
min-width: 0;
}
.extensions-header {
margin-bottom: 1rem;
}
.extensions-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.tab-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn:hover {
background: var(--bg-accent);
color: var(--text-primary);
}
.tab-btn.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.extensions-grid {
display: grid;
gap: 1rem;
}
.extension-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.extension-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-block), 0 8px 16px rgba(0, 0, 0, 0.1);
}
.extension-card[data-type="game"] {
border-left: 4px solid var(--success-color);
}
.extension-card[data-type="mod"] {
border-left: 4px solid var(--primary-color);
}
.extension-card[data-type="txp"] {
border-left: 4px solid var(--warning-color);
}
.extension-title h4 {
margin: 0;
color: var(--text-primary);
}
.extension-description {
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: 0.9rem;
line-height: 1.4;
}
.extension-meta {
background: var(--bg-accent);
padding: 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.extension-badges {
display: flex;
gap: 0.5rem;
align-items: center;
}
.stat-item {
text-align: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-item strong {
display: block;
font-size: 1.5rem;
color: var(--primary-color);
}
.stat-item span {
font-size: 0.875rem;
color: var(--text-muted);
}
.meta-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.meta-item:last-child {
margin-bottom: 0;
}
.location-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.location-badge.global {
background: var(--success-color);
color: white;
}
.location-badge.world {
background: var(--primary-color);
color: white;
}
.dependencies {
background: var(--bg-secondary);
padding: 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.dep-list {
margin-top: 0.5rem;
color: var(--text-muted);
font-family: monospace;
}
.extension-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.badge-primary {
background: var(--primary-color);
color: white;
}
.badge-success {
background: var(--success-color);
color: white;
}
.badge-warning {
background: var(--warning-color);
color: white;
}
@media (max-width: 768px) {
.extensions-layout {
flex-direction: column;
}
.extensions-sidebar {
flex: none;
order: 2;
}
.extensions-main {
order: 1;
}
.extensions-tabs {
flex-direction: column;
}
.tab-btn {
text-align: center;
}
.extension-actions {
justify-content: stretch;
}
.extension-actions .btn {
flex: 1;
text-align: center;
}
.meta-item {
flex-direction: column;
gap: 0.25rem;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const quickInstallForm = document.getElementById('quickInstallForm');
const quickPackageUrlInput = document.getElementById('quickPackageUrl');
const quickInstallLocationSelect = document.getElementById('quickInstallLocation');
const quickWorldNameSelect = document.getElementById('quickWorldName');
const quickLocationGroup = document.getElementById('quickLocationGroup');
const quickInstallBtn = document.getElementById('quickInstallBtn');
const quickInstallStatus = document.getElementById('quickInstallStatus');
const quickUrlValidation = document.getElementById('quickUrlValidation');
// Load available worlds
loadWorlds();
// Show/hide world selection
quickInstallLocationSelect.addEventListener('change', function() {
if (this.value === 'world') {
quickWorldNameSelect.style.display = 'block';
quickWorldNameSelect.required = true;
} else {
quickWorldNameSelect.style.display = 'none';
quickWorldNameSelect.required = false;
}
});
// URL validation
let validationTimeout;
quickPackageUrlInput.addEventListener('input', function() {
clearTimeout(validationTimeout);
validationTimeout = setTimeout(() => {
validateUrl(this.value);
}, 500);
});
// Quick install form submission
quickInstallForm.addEventListener('submit', async function(e) {
e.preventDefault();
const url = quickPackageUrlInput.value.trim();
if (!url) {
showQuickStatus('Please enter a package URL', 'danger');
return;
}
quickInstallBtn.disabled = true;
quickInstallBtn.textContent = '⏳ Installing...';
showQuickStatus('Installing package...', 'info');
try {
const formData = new FormData(this);
const params = new URLSearchParams();
for (let [key, value] of formData.entries()) {
params.append(key, value);
}
const response = await fetch('/extensions/install-url', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: params
});
const result = await response.json();
if (result.success) {
showQuickStatus(result.message + ' ✅', 'success');
quickInstallForm.reset();
quickWorldNameSelect.style.display = 'none';
quickWorldNameSelect.required = false;
// Reload page after 2 seconds to show new extension
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
// Handle specific validation errors with better messaging
if (result.type === 'invalid_installation_target' && result.packageType === 'game') {
showQuickStatus('❌ ' + result.error, 'warning');
} else {
showQuickStatus(result.error || 'Installation failed', 'danger');
}
}
} catch (error) {
console.error('Installation error:', error);
showQuickStatus('Installation failed: ' + error.message, 'danger');
} finally {
quickInstallBtn.disabled = false;
quickInstallBtn.textContent = '📦 Install';
}
});
async function loadWorlds() {
try {
const response = await fetch('/api/worlds');
const worlds = await response.json();
quickWorldNameSelect.innerHTML = '<option value="">Select a world...</option>';
worlds.forEach(world => {
const option = document.createElement('option');
option.value = world.name;
option.textContent = world.displayName || world.name;
quickWorldNameSelect.appendChild(option);
});
} catch (error) {
console.error('Failed to load worlds:', error);
}
}
async function validateUrl(url) {
if (!url.trim()) {
quickUrlValidation.innerHTML = '';
resetLocationOptions();
return;
}
const parsed = parseContentDBUrl(url);
if (parsed.author && parsed.name) {
quickUrlValidation.innerHTML = '<small class="text-info">🔄 Checking package...</small>';
try {
// Check package type via API
const response = await fetch('/api/contentdb/package-info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: parsed.author, name: parsed.name })
});
if (response.ok) {
const packageInfo = await response.json();
const packageType = packageInfo.type || 'mod';
if (packageType === 'game') {
quickUrlValidation.innerHTML = '<small class="text-success">✅ Game: ' + parsed.author + '/' + parsed.name + '</small>';
restrictLocationOptionsForGame();
} else {
quickUrlValidation.innerHTML = '<small class="text-success">✅ ' + packageType.charAt(0).toUpperCase() + packageType.slice(1) + ': ' + parsed.author + '/' + parsed.name + '</small>';
resetLocationOptions();
}
} else {
quickUrlValidation.innerHTML = '<small class="text-success">✅ Valid: ' + parsed.author + '/' + parsed.name + '</small>';
resetLocationOptions();
}
} catch (error) {
quickUrlValidation.innerHTML = '<small class="text-success">✅ Valid: ' + parsed.author + '/' + parsed.name + '</small>';
resetLocationOptions();
}
} else {
quickUrlValidation.innerHTML = '<small class="text-danger">❌ Invalid URL format</small>';
resetLocationOptions();
}
}
function restrictLocationOptionsForGame() {
// For games, only allow global installation
quickInstallLocationSelect.innerHTML = '<option value="global">Global (Games are shared across all worlds)</option>';
quickInstallLocationSelect.disabled = true;
quickWorldNameSelect.style.display = 'none';
quickWorldNameSelect.required = false;
// Add explanation
const existingWarning = document.getElementById('game-warning');
if (!existingWarning) {
const warning = document.createElement('div');
warning.id = 'game-warning';
warning.className = 'alert alert-info mt-2';
warning.innerHTML = '<small><strong> Note:</strong> Games are installed globally and shared across all worlds. To use this game, create a new world and select it during world creation.</small>';
quickLocationGroup.appendChild(warning);
}
}
function resetLocationOptions() {
// Reset to normal options
quickInstallLocationSelect.innerHTML =
'<option value="global">Global</option>' +
'<option value="world">Specific World</option>';
quickInstallLocationSelect.disabled = false;
// Remove warning if it exists
const warning = document.getElementById('game-warning');
if (warning) {
warning.remove();
}
// Reset world selection based on current value
if (quickInstallLocationSelect.value === 'world') {
quickWorldNameSelect.style.display = 'block';
quickWorldNameSelect.required = true;
} else {
quickWorldNameSelect.style.display = 'none';
quickWorldNameSelect.required = false;
}
}
function parseContentDBUrl(url) {
url = url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
const patterns = [
/^content\\.luanti\\.org\\/packages\\/([^/]+)\\/([^/]+)$/,
/^([^/]+)\\/([^/]+)$/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return { author: match[1], name: match[2] };
}
}
return { author: null, name: null };
}
function showQuickStatus(message, type) {
const statusAlert = document.getElementById('quickStatusAlert');
const statusMessage = document.getElementById('quickStatusMessage');
const alertClass = 'alert-' + type;
statusAlert.className = 'alert mt-2 ' + alertClass;
statusMessage.textContent = message;
quickInstallStatus.style.display = 'block';
}
});
function filterExtensions(type) {
const cards = document.querySelectorAll('.extension-card');
const tabs = document.querySelectorAll('.tab-btn');
// Update active tab
tabs.forEach(tab => tab.classList.remove('active'));
event.target.classList.add('active');
// Filter cards
cards.forEach(card => {
if (type === 'all') {
card.style.display = 'block';
} else {
const cardType = card.getAttribute('data-type');
card.style.display = cardType === type ? 'block' : 'none';
}
});
}
function checkForUpdate(author, name) {
alert('Update checking feature coming soon!');
// TODO: Implement update checking
}
function uninstallExtension(name, type, location) {
if (confirm('Are you sure you want to remove ' + name + '?\\n\\nThis will permanently delete the extension files.')) {
alert('Uninstall feature coming soon!');
// TODO: Implement extension removal
}
}
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'extensions', title: title }) %>