Files
LuHost/views/extensions/index.ejs
Nathan Schneider 3aed09b60f 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>
2025-08-23 17:32:37 -06:00

693 lines
24 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<%
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 }) %>