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:
429
views/contentdb/index.ejs
Normal file
429
views/contentdb/index.ejs
Normal file
@@ -0,0 +1,429 @@
|
||||
<%
|
||||
const body = `
|
||||
<div class="page-header">
|
||||
<h2>ContentDB Installer</h2>
|
||||
<p>Install mods, games, and texture packs by pasting ContentDB URLs</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Install from URL</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/contentdb/install-url" id="installForm">
|
||||
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||
<div class="form-group">
|
||||
<label for="packageUrl">ContentDB Package URL*</label>
|
||||
<input type="text"
|
||||
id="packageUrl"
|
||||
name="packageUrl"
|
||||
class="form-control"
|
||||
placeholder="https://content.luanti.org/packages/author/package_name/"
|
||||
required>
|
||||
<small class="form-text text-muted">
|
||||
Paste any ContentDB package URL or use format: author/package_name
|
||||
</small>
|
||||
<div id="urlValidation" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="locationSelectionGroup">
|
||||
<div class="form-group">
|
||||
<label for="installLocation">Install Location</label>
|
||||
<select name="installLocation" id="installLocation" class="form-control" required>
|
||||
<option value="global">Global</option>
|
||||
<option value="world">Specific World</option>
|
||||
</select>
|
||||
<small class="form-text text-muted" id="locationHelp">Choose where to install this content</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="worldSelectionGroup" style="display: none;">
|
||||
<label for="worldName">Target World</label>
|
||||
<select name="worldName" id="worldName" class="form-control">
|
||||
<option value="">Select a world...</option>
|
||||
<!-- Will be populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="dependencyGroup">
|
||||
<div class="checkbox-wrapper">
|
||||
<input type="checkbox" id="installDeps" name="installDeps" checked>
|
||||
<label for="installDeps" class="checkbox-label">
|
||||
Install dependencies automatically
|
||||
</label>
|
||||
</div>
|
||||
<small class="form-text text-muted" id="depsHelp">
|
||||
Recommended: Automatically download and install required dependencies
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-success btn-lg" id="installBtn">
|
||||
📦 Install Package
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="clearForm()">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="installStatus" style="display: none;">
|
||||
<div class="alert" id="statusAlert">
|
||||
<div id="statusMessage"></div>
|
||||
<div id="installProgress" class="mt-2">
|
||||
<div class="spinner"></div>
|
||||
<span>Installing package...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>How to Use</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h4>📋 Step 1: Copy URL</h4>
|
||||
<p>Go to <a href="https://content.luanti.org" target="_blank">content.luanti.org</a> and copy the URL of any content (mods, games, texture packs).</p>
|
||||
|
||||
<h4>📍 Step 2: Auto-Detection</h4>
|
||||
<p><strong>Games</strong> install automatically to games directory.<br>
|
||||
<strong>Mods</strong> let you choose global or world-specific.<br>
|
||||
<strong>Texture packs</strong> install automatically to textures directory.</p>
|
||||
|
||||
<h4>⚡ Step 3: Install</h4>
|
||||
<p>Click install and dependencies will be resolved automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>🔄 Package Updates</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Check for updates to your installed packages:</p>
|
||||
<a href="/contentdb/updates" class="btn btn-primary btn-block">Check for Updates</a>
|
||||
<a href="/contentdb/installed" class="btn btn-outline-primary btn-block">View Installed</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>📝 Supported URL Formats</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="url-examples">
|
||||
<h4>✅ Supported Formats:</h4>
|
||||
<ul>
|
||||
<li><code>https://content.luanti.org/packages/author/package_name/</code></li>
|
||||
<li><code>content.luanti.org/packages/author/package_name/</code></li>
|
||||
<li><code>author/package_name</code> (direct format)</li>
|
||||
</ul>
|
||||
|
||||
<h4>📋 Example URLs:</h4>
|
||||
<ul>
|
||||
<li><strong>Mod:</strong> <code>https://content.luanti.org/packages/VanessaE/basic_materials/</code></li>
|
||||
<li><strong>Game:</strong> <code>https://content.luanti.org/packages/GreenXenith/nodecore/</code></li>
|
||||
<li><strong>Texture Pack:</strong> <code>https://content.luanti.org/packages/author/texture_pack/</code></li>
|
||||
<li><strong>Direct:</strong> <code>VanessaE/basic_materials</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.url-examples ul {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.url-examples li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.url-examples code {
|
||||
background: var(--bg-accent);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#urlValidation {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.validation-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.validation-info {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
#installProgress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const packageUrlInput = document.getElementById('packageUrl');
|
||||
const installLocationSelect = document.getElementById('installLocation');
|
||||
const worldSelectionGroup = document.getElementById('worldSelectionGroup');
|
||||
const worldNameSelect = document.getElementById('worldName');
|
||||
const installForm = document.getElementById('installForm');
|
||||
const installBtn = document.getElementById('installBtn');
|
||||
const installStatus = document.getElementById('installStatus');
|
||||
const urlValidation = document.getElementById('urlValidation');
|
||||
const locationSelectionGroup = document.getElementById('locationSelectionGroup');
|
||||
const dependencyGroup = document.getElementById('dependencyGroup');
|
||||
const locationHelp = document.getElementById('locationHelp');
|
||||
const depsHelp = document.getElementById('depsHelp');
|
||||
|
||||
let currentPackageType = null;
|
||||
|
||||
// Load available worlds
|
||||
loadWorlds();
|
||||
|
||||
// Show/hide world selection based on install location
|
||||
installLocationSelect.addEventListener('change', function() {
|
||||
if (this.value === 'world') {
|
||||
worldSelectionGroup.style.display = 'block';
|
||||
worldNameSelect.required = true;
|
||||
} else {
|
||||
worldSelectionGroup.style.display = 'none';
|
||||
worldNameSelect.required = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time URL validation
|
||||
let validationTimeout;
|
||||
packageUrlInput.addEventListener('input', function() {
|
||||
clearTimeout(validationTimeout);
|
||||
validationTimeout = setTimeout(() => {
|
||||
validateUrl(this.value);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Form submission
|
||||
installForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const url = packageUrlInput.value.trim();
|
||||
if (!url) {
|
||||
showError('Please enter a package URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show installation status
|
||||
installBtn.disabled = true;
|
||||
installBtn.textContent = '⏳ Installing...';
|
||||
showStatus('Installing package...', 'info', true);
|
||||
|
||||
try {
|
||||
const formData = new FormData(this);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Convert FormData to URLSearchParams for proper encoding
|
||||
for (let [key, value] of formData.entries()) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
const response = await fetch('/contentdb/install-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showStatus(result.message + ' ✅', 'success', false);
|
||||
clearForm();
|
||||
|
||||
// Auto-hide success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
installStatus.style.display = 'none';
|
||||
}, 5000);
|
||||
} else {
|
||||
showStatus(result.error || 'Installation failed', 'error', false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Installation error:', error);
|
||||
showStatus('Installation failed: ' + error.message, 'error', false);
|
||||
} finally {
|
||||
installBtn.disabled = false;
|
||||
installBtn.textContent = '📦 Install Package';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadWorlds() {
|
||||
try {
|
||||
const response = await fetch('/api/worlds');
|
||||
const worlds = await response.json();
|
||||
|
||||
worldNameSelect.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;
|
||||
worldNameSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load worlds:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUrl(url) {
|
||||
if (!url.trim()) {
|
||||
urlValidation.innerHTML = '';
|
||||
resetUIForPackageType();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse URL client-side for immediate feedback
|
||||
const parsed = parseContentDBUrl(url);
|
||||
|
||||
if (parsed.author && parsed.name) {
|
||||
// Show valid URL format - type detection happens during installation
|
||||
urlValidation.innerHTML = '<span class="validation-success">✅ Valid: ' + parsed.author + '/' + parsed.name + ' (type will be detected during installation)</span>';
|
||||
resetUIForPackageType();
|
||||
} else {
|
||||
urlValidation.innerHTML = '<span class="validation-error">❌ Invalid URL format</span>';
|
||||
resetUIForPackageType();
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentDBUrl(url) {
|
||||
// Remove protocol and clean up
|
||||
url = url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
|
||||
|
||||
// Match patterns
|
||||
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 updateUIForPackageType(packageType, author, name, title) {
|
||||
const typeDisplayName = packageType === 'game' ? 'Game' :
|
||||
packageType === 'txp' ? 'Texture Pack' : 'Mod';
|
||||
const typeEmoji = packageType === 'game' ? '🎮' :
|
||||
packageType === 'txp' ? '🎨' : '📦';
|
||||
|
||||
urlValidation.innerHTML = \`<span class="validation-success">✅ \${typeEmoji} \${typeDisplayName}: \${title || (author + '/' + name)}</span>\`;
|
||||
|
||||
if (packageType === 'game') {
|
||||
// Games go to games directory - no location choice
|
||||
locationSelectionGroup.style.display = 'none';
|
||||
dependencyGroup.style.display = 'none';
|
||||
installBtn.innerHTML = '🎮 Install Game';
|
||||
} else if (packageType === 'txp') {
|
||||
// Texture packs go to textures directory - no location choice
|
||||
locationSelectionGroup.style.display = 'none';
|
||||
dependencyGroup.style.display = 'none';
|
||||
installBtn.innerHTML = '🎨 Install Texture Pack';
|
||||
} else {
|
||||
// Mods can be installed globally or per-world
|
||||
locationSelectionGroup.style.display = 'block';
|
||||
dependencyGroup.style.display = 'block';
|
||||
locationHelp.textContent = 'Choose where to install this mod';
|
||||
depsHelp.textContent = 'Recommended: Automatically download and install required dependencies';
|
||||
installBtn.innerHTML = '📦 Install Mod';
|
||||
}
|
||||
}
|
||||
|
||||
function resetUIForPackageType() {
|
||||
currentPackageType = null;
|
||||
locationSelectionGroup.style.display = 'block';
|
||||
dependencyGroup.style.display = 'block';
|
||||
installBtn.innerHTML = '📦 Install Package';
|
||||
locationHelp.textContent = 'Choose where to install this content';
|
||||
depsHelp.textContent = 'Recommended: Automatically download and install required dependencies';
|
||||
}
|
||||
|
||||
function showStatus(message, type, showProgress) {
|
||||
const statusAlert = document.getElementById('statusAlert');
|
||||
const statusMessage = document.getElementById('statusMessage');
|
||||
const installProgress = document.getElementById('installProgress');
|
||||
|
||||
// Map alert types to Bootstrap classes
|
||||
const alertClass = type === 'error' ? 'alert-danger' : type === 'info' ? 'alert-info' : 'alert-' + type;
|
||||
|
||||
statusAlert.className = 'alert ' + alertClass;
|
||||
statusMessage.textContent = message;
|
||||
installProgress.style.display = showProgress ? 'flex' : 'none';
|
||||
installStatus.style.display = 'block';
|
||||
|
||||
// Scroll to status for better visibility
|
||||
installStatus.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
showStatus(message, 'danger', false);
|
||||
}
|
||||
|
||||
window.clearForm = function() {
|
||||
installForm.reset();
|
||||
urlValidation.innerHTML = '';
|
||||
installStatus.style.display = 'none';
|
||||
worldSelectionGroup.style.display = 'none';
|
||||
worldNameSelect.required = false;
|
||||
resetUIForPackageType();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
`;
|
||||
%>
|
||||
|
||||
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>
|
Reference in New Issue
Block a user