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

63
views/auth/login.ejs Normal file
View File

@@ -0,0 +1,63 @@
<%
const body = `
<div style="max-width: 400px; margin: 2rem auto;">
<div class="card">
<div class="card-header" style="text-align: center;">
<h2>Login to Luanti Server Manager</h2>
<p style="color: var(--text-secondary); margin: 0;">Enter your credentials to access the server management interface</p>
</div>
${typeof error !== 'undefined' ? `
<div class="alert alert-danger">
<strong>Error:</strong> ${typeof escapeHtml !== 'undefined' ? escapeHtml(error) : error}
</div>
` : ''}
${typeof req !== 'undefined' && req.query.message ? `
<div class="alert alert-info">
${req.query.message}
</div>
` : ''}
<form method="POST" action="/login">
<input type="hidden" name="redirect" value="${redirectUrl || '/'}">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label for="username">Username</label>
<input type="text"
id="username"
name="username"
class="form-control"
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
required
autofocus
autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
autocomplete="current-password">
</div>
<div style="display: flex; justify-content: flex-end; align-items: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary">
Sign In
</button>
</div>
</form>
</div>
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<p>Need an account? Contact an existing administrator to create one for you.</p>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'login', title: title }) %>

116
views/auth/register.ejs Normal file
View File

@@ -0,0 +1,116 @@
<%
const body = `
<div style="max-width: 500px; margin: 2rem auto;">
<div class="card">
<div class="card-header" style="text-align: center;">
<h2>${isFirstUser ? 'Setup Administrator Account' : 'Create Account'}</h2>
<p style="color: var(--text-secondary); margin: 0;">
${isFirstUser ?
'Create the first administrator account for this Luanti server' :
'Join this Luanti server management team'
}
</p>
</div>
${typeof error !== 'undefined' ? `
<div class="alert alert-danger">
<strong>Error:</strong> ${error}
</div>
` : ''}
${isFirstUser ? `
<div class="alert alert-info">
<strong>First User Setup:</strong> You are creating the first administrator account for this server. All users have full admin privileges.
</div>
` : ''}
<form method="POST" action="/register">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label for="username">Username*</label>
<input type="text"
id="username"
name="username"
class="form-control"
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
required
pattern="[a-zA-Z0-9_-]{3,20}"
title="3-20 characters, letters, numbers, underscore, or hyphen only"
data-validate-name
autofocus
autocomplete="username">
<small style="color: var(--text-secondary);">3-20 characters, letters, numbers, underscore, or hyphen only</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="password">Password*</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
minlength="8"
autocomplete="new-password">
<small style="color: var(--text-secondary);">At least 8 characters long</small>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password*</label>
<input type="password"
id="confirmPassword"
name="confirmPassword"
class="form-control"
required
minlength="8"
autocomplete="new-password">
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
<a href="/login" class="btn btn-outline">
Already have an account?
</a>
<button type="submit" class="btn btn-success">
${isFirstUser ? 'Setup Account' : 'Create Account'}
</button>
</div>
</form>
</div>
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<p>
${isFirstUser ?
'This will be the primary administrator account.' :
'All accounts have full server administration privileges.'
}
</p>
</div>
</div>
<script>
// Client-side password confirmation validation
document.getElementById('confirmPassword').addEventListener('input', function() {
const password = document.getElementById('password').value;
const confirmPassword = this.value;
if (password && confirmPassword) {
if (password !== confirmPassword) {
this.setCustomValidity('Passwords do not match');
} else {
this.setCustomValidity('');
}
}
});
document.getElementById('password').addEventListener('input', function() {
const confirmPassword = document.getElementById('confirmPassword');
if (confirmPassword.value) {
confirmPassword.dispatchEvent(new Event('input'));
}
});
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'register', title: title }) %>

687
views/config/index.ejs Normal file
View File

@@ -0,0 +1,687 @@
<%
const body = `
<div class="page-header">
<h2>⚙️ Server Configuration</h2>
<p>Configure your Luanti server's global settings</p>
<div class="config-help">
<small class="text-muted">
💡 These are global server settings. For world-specific settings, visit
<a href="/worlds">🌍 World Management</a>
</small>
</div>
</div>
<div class="row">
<div class="col-md-9">
<div class="card">
<div class="card-header">
<div class="config-header-flex">
<h3>⚙️ Server Configuration</h3>
<div class="config-status" id="configStatus">
<span class="status-indicator saved">✅ Saved</span>
</div>
</div>
<p class="section-description">Configure all server settings below</p>
</div>
<div class="card-body">
<form id="configForm">
<div id="configSections">
<!-- All configuration sections will be loaded here -->
<div class="loading">Loading configuration...</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h3>💾 Actions</h3>
</div>
<div class="card-body">
<div class="config-actions">
<button class="btn btn-success btn-sm btn-block" id="saveBtn">
💾 Save Changes
</button>
<button class="btn btn-outline-secondary btn-sm btn-block" id="reloadBtn">
🔄 Reload
</button>
<button class="btn btn-outline-warning btn-sm btn-block" id="resetBtn">
↩️ Reset All
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>📝 Configuration File</h3>
</div>
<div class="card-body">
<p class="text-muted small">
Location: <code id="configPath">~/.minetest/minetest.conf</code>
</p>
<button class="btn btn-outline-info btn-sm btn-block" id="downloadBtn">
📄 Download Config
</button>
</div>
</div>
<div class="card">
<div class="card-header">
<h3> Status</h3>
</div>
<div class="card-body">
<div class="status-info">
<div class="info-item">
<strong>Data Directory:</strong><br>
<small id="currentDataDir">Loading...</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal temporarily removed for debugging -->
<style>
.config-help {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--bg-accent);
border-radius: var(--border-radius);
border-left: 4px solid var(--primary-color);
}
.config-header-flex {
display: flex;
justify-content: space-between;
align-items: center;
}
.config-divider {
margin: 1rem 0;
}
/* Navigation styles removed - now showing all sections */
.config-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.section-description {
margin: 0.5rem 0 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.config-status {
font-size: 0.9rem;
}
.status-indicator {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: bold;
}
.status-indicator.saved {
color: var(--success-color);
}
.status-indicator.modified {
background: var(--warning-color);
color: white;
}
.status-indicator.error {
background: var(--danger-color);
color: white;
}
.config-section-header {
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
}
.config-section-header:first-child {
margin-top: 0;
}
.config-section-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.25rem;
}
.config-section-header p {
margin: 0.25rem 0 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.config-section-content {
margin-bottom: 2rem;
}
.setting-group {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--bg-secondary);
}
.setting-label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: var(--text-primary);
}
.setting-description {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
line-height: 1.4;
}
.setting-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--bg-primary);
color: var(--text-primary);
}
.setting-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
}
.setting-input.number {
max-width: 200px;
}
.setting-input.boolean {
width: auto;
}
.setting-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.setting-default {
font-style: italic;
}
.setting-validation {
color: var(--danger-color);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
/* Modal styles temporarily removed for debugging */
.status-info .info-item {
margin-bottom: 1rem;
padding: 0.5rem;
background: var(--bg-accent);
border-radius: var(--border-radius);
}
.status-info .info-item:last-child {
margin-bottom: 0;
}
@media (max-width: 768px) {
.config-nav {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.nav-btn {
text-align: center;
font-size: 0.8rem;
padding: 0.5rem;
}
/* Modal media query removed */
}
</style>
<script>
// Configuration page JavaScript - properly outside template literal
let configData = {};
let originalConfig = {};
let configSections = {};
let unsavedChanges = false;
console.log('Configuration page JavaScript is loading...');
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM Content Loaded - Config Page');
loadConfiguration();
// Add event listeners for action buttons
const saveBtn = document.getElementById('saveBtn');
if (saveBtn) {
saveBtn.addEventListener('click', saveConfiguration);
}
const reloadBtn = document.getElementById('reloadBtn');
if (reloadBtn) {
reloadBtn.addEventListener('click', reloadConfiguration);
}
const resetBtn = document.getElementById('resetBtn');
if (resetBtn) {
resetBtn.addEventListener('click', resetSection);
}
const downloadBtn = document.getElementById('downloadBtn');
if (downloadBtn) {
downloadBtn.addEventListener('click', downloadConfig);
}
// Warn about unsaved changes
window.addEventListener('beforeunload', function(e) {
if (unsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
});
async function loadConfiguration() {
try {
console.log('loadConfiguration function started...');
console.log('About to fetch /api/config...');
const response = await fetch('/api/config');
console.log('Fetch response:', response);
if (!response.ok) {
console.error('Response not ok:', response.status, response.statusText);
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
console.log('About to parse JSON...');
const data = await response.json();
console.log('Configuration data received:', data);
console.log('Data keys:', Object.keys(data));
configData = data.current;
originalConfig = { ...data.current };
configSections = data.sections;
console.log('About to render config sections...');
renderConfigSections();
console.log('Config sections rendered, updating status...');
updateStatus('saved');
// Update status info
document.getElementById('currentDataDir').textContent = data.current.data_directory || 'Not configured';
document.getElementById('configPath').textContent = (data.current.data_directory || '~/.minetest') + '/minetest.conf';
} catch (error) {
console.error('Failed to load configuration:', error);
console.error('Error type:', typeof error);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
// Show a basic fallback form
const sectionsElement = document.getElementById('configSections');
console.log('Setting error content to element:', sectionsElement);
if (sectionsElement) {
sectionsElement.innerHTML =
'<div class="alert alert-warning">' +
'<strong>⚠️ Configuration Loading Failed</strong><br>' +
error.message + '<br>' +
'Showing basic form instead.' +
'</div>';
} else {
console.error('configSections element not found!');
}
}
}
function renderConfigSections() {
const container = document.getElementById('configSections');
container.innerHTML = '';
// Icons for each section
const sectionIcons = {
'System': '🏗️',
'Server': '🖥️',
'World': '🌍',
'Performance': '⚡',
'Security': '🔒',
'Network': '🌐',
'Advanced': '🔧'
};
for (const [sectionName, section] of Object.entries(configSections)) {
// Create section header
const sectionHeaderDiv = document.createElement('div');
sectionHeaderDiv.className = 'config-section-header';
sectionHeaderDiv.innerHTML =
'<h3>' + (sectionIcons[sectionName] || '⚙️') + ' ' + sectionName + ' Configuration</h3>' +
'<p>' + (section.description || (sectionName + ' configuration settings')) + '</p>';
container.appendChild(sectionHeaderDiv);
// Create section content
const sectionDiv = document.createElement('div');
sectionDiv.className = 'config-section-content';
if (section.note) {
const noteDiv = document.createElement('div');
noteDiv.className = 'alert alert-info';
noteDiv.innerHTML = '💡 ' + section.note;
sectionDiv.appendChild(noteDiv);
}
for (const [settingKey, setting] of Object.entries(section.settings)) {
const settingDiv = document.createElement('div');
settingDiv.className = 'setting-group';
const currentValue = configData[settingKey] !== undefined ?
configData[settingKey] : setting.default;
settingDiv.innerHTML =
'<label class="setting-label" for="' + settingKey + '">' +
settingKey +
'</label>' +
'<div class="setting-description">' +
setting.description +
'</div>' +
renderSettingInput(settingKey, setting, currentValue) +
'<div class="setting-meta">' +
'<span class="setting-default">Default: ' + setting.default + '</span>' +
'<span class="setting-type">' + setting.type + '</span>' +
'</div>' +
'<div class="setting-validation" id="validation-' + settingKey + '"></div>';
sectionDiv.appendChild(settingDiv);
}
container.appendChild(sectionDiv);
}
// Add event listeners for changes
container.addEventListener('input', handleSettingChange);
container.addEventListener('change', handleSettingChange);
}
function renderSettingInput(key, setting, value) {
switch (setting.type) {
case 'boolean':
return '<input type="checkbox" ' +
'id="' + key + '" ' +
'name="' + key + '" ' +
'class="setting-input boolean" ' +
(value ? 'checked' : '') + '>';
case 'number':
const step = setting.step || (setting.min !== undefined && setting.min < 1 ? '0.01' : '1');
return '<input type="number" ' +
'id="' + key + '" ' +
'name="' + key + '" ' +
'class="setting-input number" ' +
'value="' + value + '"' +
'step="' + step + '"' +
(setting.min !== undefined ? 'min="' + setting.min + '"' : '') +
(setting.max !== undefined ? 'max="' + setting.max + '"' : '') + '>';
case 'text':
return '<textarea id="' + key + '" ' +
'name="' + key + '" ' +
'class="setting-input" ' +
'rows="3">' + (value || '') + '</textarea>';
default: // string
return '<input type="text" ' +
'id="' + key + '" ' +
'name="' + key + '" ' +
'class="setting-input" ' +
'value="' + (value || '') + '">';
}
}
function handleSettingChange(event) {
const input = event.target;
const key = input.name;
if (!key) return;
let value;
if (input.type === 'checkbox') {
value = input.checked;
} else if (input.type === 'number') {
value = parseFloat(input.value) || 0;
} else {
value = input.value;
}
configData[key] = value;
// Validate setting
validateSetting(key, value);
// Mark as modified
if (JSON.stringify(configData) !== JSON.stringify(originalConfig)) {
updateStatus('modified');
unsavedChanges = true;
} else {
updateStatus('saved');
unsavedChanges = false;
}
}
function validateSetting(key, value) {
const validationElement = document.getElementById('validation-' + key);
if (!validationElement) return;
// Find setting definition
let setting = null;
for (const section of Object.values(configSections)) {
if (section.settings && section.settings[key]) {
setting = section.settings[key];
break;
}
}
if (!setting) return;
// Validate based on type
let isValid = true;
let errorMessage = '';
if (setting.type === 'number') {
const num = Number(value);
if (isNaN(num)) {
isValid = false;
errorMessage = 'Must be a number';
} else {
if (setting.min !== undefined && num < setting.min) {
isValid = false;
errorMessage = 'Must be at least ' + setting.min;
}
if (setting.max !== undefined && num > setting.max) {
isValid = false;
errorMessage = 'Must be at most ' + setting.max;
}
}
}
if (isValid) {
validationElement.textContent = '';
const input = document.getElementById(key);
if (input) input.style.borderColor = '';
} else {
validationElement.textContent = errorMessage;
const input = document.getElementById(key);
if (input) input.style.borderColor = 'var(--danger-color)';
}
return isValid;
}
// showSection function removed - now showing all sections at once
async function saveConfiguration() {
try {
// Validate all current settings
let hasErrors = false;
for (const [key, value] of Object.entries(configData)) {
if (!validateSetting(key, value)) {
hasErrors = true;
}
}
if (hasErrors) {
updateStatus('error');
alert('Please fix validation errors before saving.');
return;
}
updateStatus('saving');
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: configData })
});
const result = await response.json();
if (result.success) {
originalConfig = { ...configData };
unsavedChanges = false;
updateStatus('saved');
// Show success message briefly
const statusElement = document.getElementById('configStatus');
if (statusElement) {
statusElement.innerHTML = '<span class="status-indicator saved">✅ Configuration saved!</span>';
setTimeout(() => {
statusElement.innerHTML = '<span class="status-indicator saved">✅ Saved</span>';
}, 3000);
}
} else {
updateStatus('error');
alert('Failed to save configuration: ' + (result.error || 'Unknown error'));
}
} catch (error) {
updateStatus('error');
alert('Failed to save configuration: ' + error.message);
}
}
async function reloadConfiguration() {
if (unsavedChanges) {
if (!confirm('You have unsaved changes. Are you sure you want to reload?')) {
return;
}
}
await loadConfiguration();
}
async function resetSection() {
if (!confirm('Reset all configuration settings to defaults?')) {
return;
}
try {
const response = await fetch('/api/config/reset', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
await loadConfiguration();
} else {
alert('Failed to reset configuration: ' + (result.error || 'Unknown error'));
}
} catch (error) {
alert('Failed to reset configuration: ' + error.message);
}
}
async function downloadConfig() {
try {
const response = await fetch('/api/config');
const data = await response.json();
// Generate config file content
let content = '# Minetest configuration file\\n';
content += '# Generated by LuHost\\n\\n';
for (const [key, value] of Object.entries(data.current)) {
content += key + ' = ' + value + '\\n';
}
// Download as file
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'minetest.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
alert('Failed to download config: ' + error.message);
}
}
function updateStatus(status) {
const statusElement = document.getElementById('configStatus');
if (!statusElement) return;
switch (status) {
case 'saved':
statusElement.innerHTML = '<span class="status-indicator saved">✅ Saved</span>';
break;
case 'modified':
statusElement.innerHTML = '<span class="status-indicator modified">⚠️ Modified</span>';
break;
case 'saving':
statusElement.innerHTML = '<span class="status-indicator">💾 Saving...</span>';
break;
case 'error':
statusElement.innerHTML = '<span class="status-indicator error">❌ Error</span>';
break;
}
}
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'config', title: title }) %>

429
views/contentdb/index.ejs Normal file
View 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 }) %>

View File

@@ -0,0 +1,308 @@
<%
const body = `
<div class="page-header">
<h2>📦 Installed Packages</h2>
<p>Manage your installed mods, games, and texture packs</p>
</div>
<div class="row">
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h3>📊 Statistics</h3>
</div>
<div class="card-body">
<div class="stat-item">
<strong>${statistics.total_packages || 0}</strong>
<span>Total Packages</span>
</div>
<div class="stat-item">
<strong>${statistics.global_packages || 0}</strong>
<span>Global Mods</span>
</div>
<div class="stat-item">
<strong>${statistics.world_packages || 0}</strong>
<span>World-specific</span>
</div>
<div class="stat-item">
<strong>${statistics.worlds_with_packages || 0}</strong>
<span>Worlds with Mods</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>🔍 Filter Packages</h3>
</div>
<div class="card-body">
<a href="/contentdb/installed"
class="btn ${selectedLocation === 'all' ? 'btn-success' : 'btn-outline-secondary'} btn-sm btn-block">
All Locations
</a>
<a href="/contentdb/installed?location=global"
class="btn ${selectedLocation === 'global' ? 'btn-success' : 'btn-outline-secondary'} btn-sm btn-block">
Global Mods
</a>
<div class="mt-2">
<small class="text-muted">World-specific filters coming soon</small>
</div>
</div>
</div>
</div>
<div class="col-md-9">
${packages.length === 0 ? `
<div class="card">
<div class="card-body text-center">
<h3>📭 No Packages Installed</h3>
<p>You haven't installed any packages yet from ContentDB.</p>
<a href="/contentdb" class="btn btn-primary">
Browse ContentDB
</a>
</div>
</div>
` : `
<div class="packages-grid">
${packages.map(pkg => `
<div class="card package-card">
<div class="card-header">
<div class="package-title">
<h4>${pkg.title || pkg.name}</h4>
<small class="text-muted">by ${pkg.author}</small>
</div>
<div class="package-actions">
<span class="badge badge-${pkg.package_type === 'game' ? 'success' : pkg.package_type === 'txp' ? 'warning' : 'primary'}">
${pkg.package_type || 'mod'}
</span>
</div>
</div>
<div class="card-body">
<div class="package-details">
<p class="package-description">
${pkg.short_description || 'No description available.'}
</p>
<div class="package-meta">
<div class="meta-item">
<strong>Location:</strong>
<span class="location-badge ${pkg.install_location === 'global' ? 'global' : 'world'}">
${pkg.install_location === 'global' ? 'Global' : pkg.install_location.replace('world:', '')}
</span>
</div>
<div class="meta-item">
<strong>Version:</strong>
<span>${pkg.version || 'Unknown'}</span>
</div>
<div class="meta-item">
<strong>Installed:</strong>
<span>${new Date(pkg.installed_at).toLocaleDateString()}</span>
</div>
</div>
${pkg.dependencies && pkg.dependencies.length > 0 ? `
<div class="dependencies">
<strong>Dependencies (${pkg.dependencies.length}):</strong>
<div class="dep-list">
${pkg.dependencies.map(dep =>
typeof dep === 'string' ? dep : `${dep.author}/${dep.name}`
).join(', ')}
</div>
</div>
` : ''}
</div>
<div class="package-actions">
${pkg.contentdb_url ? `
<a href="\${pkg.contentdb_url}" target="_blank" class="btn btn-outline-primary btn-sm">
View on ContentDB
</a>
` : ''}
<button class="btn btn-outline-warning btn-sm"
onclick="checkForUpdate('\${pkg.author}', '\${pkg.name}', '\${pkg.install_location}')">
Check Update
</button>
<button class="btn btn-outline-danger btn-sm"
onclick="uninstallPackage('\${pkg.author}', '\${pkg.name}', '\${pkg.install_location}')">
Uninstall
</button>
</div>
</div>
</div>
`).join('')}
</div>
`}
</div>
</div>
<style>
.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);
}
.packages-grid {
display: grid;
gap: 1.5rem;
}
.package-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.package-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-block), 0 8px 16px rgba(0, 0, 0, 0.1);
}
.package-title {
flex: 1;
}
.package-title h4 {
margin: 0;
color: var(--text-primary);
}
.package-description {
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: 0.9rem;
line-height: 1.4;
}
.package-meta {
background: var(--bg-accent);
padding: 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.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;
}
.package-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.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) {
.package-actions {
justify-content: stretch;
}
.package-actions .btn {
flex: 1;
text-align: center;
}
.meta-item {
flex-direction: column;
gap: 0.25rem;
}
}
</style>
<script>
function checkForUpdate(author, name, location) {
alert('Update checking feature coming soon!');
// TODO: Implement update checking
}
function uninstallPackage(author, name, location) {
if (confirm('Are you sure you want to uninstall ' + name + '?')) {
alert('Uninstall feature coming soon!');
// TODO: Implement package uninstallation
}
}
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

View File

@@ -0,0 +1,22 @@
<%
const body = `
<div class="page-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2>Package Details</h2>
<p>View and install content from ContentDB</p>
</div>
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
</div>
</div>
<div class="card">
<div class="card-body text-center">
<p>Package details will be displayed here.</p>
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

View File

@@ -0,0 +1,22 @@
<%
const body = `
<div class="page-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2>Popular Content</h2>
<p>Most downloaded mods and games from ContentDB</p>
</div>
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
</div>
</div>
<div class="card">
<div class="card-body text-center">
<p>Popular content will be displayed here.</p>
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

View File

@@ -0,0 +1,22 @@
<%
const body = `
<div class="page-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2>Recent Content</h2>
<p>Recently added mods and games from ContentDB</p>
</div>
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
</div>
</div>
<div class="card">
<div class="card-body text-center">
<p>Recent content will be displayed here.</p>
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

305
views/contentdb/updates.ejs Normal file
View File

@@ -0,0 +1,305 @@
<%
const body = `
<div class="page-header">
<h2>🔄 Package Updates</h2>
<p>Check and install updates for your packages</p>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3>📊 Update Status</h3>
</div>
<div class="card-body">
<div class="stat-item">
<strong>${installedCount || 0}</strong>
<span>Total Packages</span>
</div>
<div class="stat-item">
<strong>${updateCount || 0}</strong>
<span>Updates Available</span>
</div>
<div class="stat-item">
<strong>${installedCount - updateCount || 0}</strong>
<span>Up to Date</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>⚡ Quick Actions</h3>
</div>
<div class="card-body">
${updateCount > 0 ? `
<button class="btn btn-success btn-block" onclick="updateAllPackages()">
📦 Update All (${updateCount})
</button>
<button class="btn btn-outline-primary btn-block" onclick="window.location.reload()">
🔄 Refresh Check
</button>
` : `
<button class="btn btn-outline-primary btn-block" onclick="window.location.reload()">
🔄 Check Again
</button>
`}
<a href="/contentdb/installed" class="btn btn-outline-secondary btn-block">
📦 View All Installed
</a>
<a href="/contentdb" class="btn btn-outline-secondary btn-block">
🌐 Browse ContentDB
</a>
</div>
</div>
</div>
<div class="col-md-8">
${updateCount === 0 ? `
<div class="card">
<div class="card-body text-center">
<h3>✅ All Packages Up to Date!</h3>
<p>All your installed packages are running the latest versions.</p>
<div class="emoji-large">🎉</div>
<p class="text-muted">
${installedCount === 0 ?
'You haven\\'t installed any packages yet.' :
\`Checked \${installedCount} package\${installedCount !== 1 ? 's' : ''}.\`
}
</p>
</div>
</div>
` : `
<div class="updates-list">
${updates.map(update => `
<div class="card update-card">
<div class="card-header">
<div class="update-title">
<h4>${update.latest.package.title || update.installed.name}</h4>
<small class="text-muted">by ${update.installed.author}</small>
</div>
<div class="update-badge">
<span class="badge badge-warning">Update Available</span>
</div>
</div>
<div class="card-body">
<div class="version-comparison">
<div class="version-item current">
<div class="version-label">Current Version</div>
<div class="version-value">${update.installed.version}</div>
<div class="version-date">
Installed: ${new Date(update.installed.installed_at).toLocaleDateString()}
</div>
</div>
<div class="version-arrow">➜</div>
<div class="version-item latest">
<div class="version-label">Latest Version</div>
<div class="version-value">${update.latest.release.title}</div>
<div class="version-date">
Released: ${new Date(update.latest.release.created_at).toLocaleDateString()}
</div>
</div>
</div>
<div class="package-location">
<strong>Location:</strong>
<span class="location-badge ${update.installed.install_location === 'global' ? 'global' : 'world'}">
${update.installed.install_location === 'global' ? 'Global' : update.installed.install_location.replace('world:', '')}
</span>
</div>
<div class="update-actions">
<button class="btn btn-success"
onclick="updatePackage('${update.installed.author}', '${update.installed.name}', '${update.installed.install_location}')">
📦 Update Now
</button>
<a href="https://content.luanti.org/packages/${update.installed.author}/${update.installed.name}/"
target="_blank"
class="btn btn-outline-primary">
View on ContentDB
</a>
</div>
</div>
</div>
`).join('')}
</div>
`}
</div>
</div>
<style>
.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);
}
.emoji-large {
font-size: 3rem;
margin: 1rem 0;
}
.updates-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.update-card {
transition: transform 0.2s ease;
}
.update-card:hover {
transform: translateY(-2px);
}
.update-title {
flex: 1;
}
.update-title h4 {
margin: 0;
color: var(--text-primary);
}
.version-comparison {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-accent);
border-radius: var(--border-radius);
}
.version-item {
flex: 1;
text-align: center;
}
.version-label {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.version-value {
font-size: 1.1rem;
font-weight: bold;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.version-date {
font-size: 0.75rem;
color: var(--text-muted);
}
.version-arrow {
font-size: 1.5rem;
color: var(--primary-color);
}
.current .version-value {
color: var(--warning-color);
}
.latest .version-value {
color: var(--success-color);
}
.package-location {
margin-bottom: 1rem;
font-size: 0.9rem;
}
.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;
}
.update-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.badge-warning {
background: var(--warning-color);
color: white;
}
@media (max-width: 768px) {
.version-comparison {
flex-direction: column;
gap: 0.5rem;
}
.version-arrow {
transform: rotate(90deg);
}
.update-actions {
flex-direction: column;
}
.update-actions .btn {
width: 100%;
}
}
</style>
<script>
function updatePackage(author, name, location) {
alert('Update functionality coming soon!');
// TODO: Implement individual package update
}
function updateAllPackages() {
if (!confirm('Update all packages? This may take a while.')) {
return;
}
alert('Bulk update functionality coming soon!');
// TODO: Implement bulk package update
}
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

142
views/dashboard.ejs Normal file
View File

@@ -0,0 +1,142 @@
<%
const body = `
<!-- Dashboard Statistics -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${stats.worlds}</div>
<div class="stat-label">Worlds</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.mods}</div>
<div class="stat-label">Mods</div>
</div>
<div class="stat-card">
<div class="stat-value">
<span id="server-status" class="status status-stopped">
Loading...
</span>
</div>
<div class="stat-label">Server Status</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size: 1rem; word-break: break-all;">
${stats.minetestDir}
</div>
<div class="stat-label">Minetest Directory</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-header">
<h2>Quick Actions</h2>
</div>
<div class="grid grid-2">
<div class="card" style="margin: 0;">
<h3>World Management</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Create and manage your game worlds
</p>
<div class="btn-group">
<a href="/worlds" class="btn btn-primary">Manage Worlds</a>
<a href="/worlds/new" class="btn btn-outline">Create World</a>
</div>
</div>
<div class="card" style="margin: 0;">
<h3>Extensions</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Manage games, mods, and texture packs
</p>
<div class="btn-group">
<a href="/extensions" class="btn btn-primary">Manage Extensions</a>
</div>
</div>
<div class="card" style="margin: 0;">
<h3>Server Control</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Start, stop, and monitor your server
</p>
<div class="btn-group">
<a href="/server" class="btn btn-primary">Server Console</a>
</div>
</div>
<div class="card" style="margin: 0;">
<h3>ContentDB Browser</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Discover new content on ContentDB
</p>
<div class="btn-group">
<a href="/contentdb" class="btn btn-primary">Browse ContentDB</a>
</div>
</div>
</div>
</div>
<!-- System Information -->
<div class="card">
<h3>System Information</h3>
<div class="table-container">
<table class="table">
<tbody>
<tr>
<td><strong>Platform</strong></td>
<td>${systemInfo.platform}</td>
</tr>
<tr>
<td><strong>Architecture</strong></td>
<td>${systemInfo.arch}</td>
</tr>
<tr>
<td><strong>Node.js Version</strong></td>
<td>${systemInfo.nodeVersion}</td>
</tr>
<tr>
<td><strong>Minetest Directory</strong></td>
<td style="word-break: break-all;">${stats.minetestDir}</td>
</tr>
<tr>
<td><strong>Server Uptime</strong></td>
<td id="server-uptime">N/A</td>
</tr>
<tr>
<td><strong>Process ID</strong></td>
<td id="server-pid">N/A</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Recent Activity (placeholder for future implementation) -->
<div class="card">
<h3>Recent Activity</h3>
<div class="empty-state">
<p>Activity logging will be implemented in a future update.</p>
<small>This will show recent world changes, mod installations, and server events.</small>
</div>
</div>
<script src="/static/js/shared-status.js"></script>
<script>
// Set current page for navigation
window.addEventListener('DOMContentLoaded', function() {
// Add any dashboard-specific JavaScript here
console.log('Dashboard loaded');
// Update page context for navigation
if (window.luantiWebServer) {
window.luantiWebServer.currentPage = 'dashboard';
}
// Load server status using shared function
updateServerStatus('server-status');
});
</script>
`;
%>
<%- include('layout', { body: body, currentPage: 'dashboard', title: title }) %>

41
views/error.ejs Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error | Luanti Server Manager</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<div class="card" style="max-width: 600px; margin: 2rem auto; text-align: center;">
<div style="font-size: 4rem; color: var(--danger-color); margin-bottom: 1rem;">
⚠️
</div>
<h1 style="color: var(--danger-color); margin-bottom: 1rem;">
<%= error %>
</h1>
<% if (typeof message !== 'undefined' && message) { %>
<div class="alert alert-danger" style="text-align: left;">
<strong>Details:</strong> <%= message %>
</div>
<% } %>
<div class="btn-group" style="margin-top: 2rem;">
<a href="javascript:history.back()" class="btn btn-secondary">
Go Back
</a>
<a href="/" class="btn btn-primary">
Return to Dashboard
</a>
</div>
<div style="margin-top: 2rem; font-size: 0.875rem; color: var(--text-secondary);">
<p>If this problem persists, please check the server logs or restart the application.</p>
</div>
</div>
</div>
</body>
</html>

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

109
views/layout.ejs Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> | LuHost</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem;">
<div style="text-align: left;">
<h1>LuHost</h1>
<p>Hosting Luanti made easy</p>
</div>
<% if (typeof isAuthenticated !== 'undefined' && isAuthenticated && typeof user !== 'undefined') { %>
<div style="text-align: right;">
<div style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem;">
Welcome, <strong><%= user.username %></strong>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span id="connection-status" class="status status-running">Connected</span>
<a href="/logout" class="btn btn-sm btn-secondary">Logout</a>
</div>
</div>
<% } else { %>
<div style="margin-top: 1rem;">
<span id="connection-status" class="status status-running">Connected</span>
</div>
<% } %>
</div>
</header>
<!-- Navigation (only show when authenticated) -->
<% if (typeof isAuthenticated !== 'undefined' && isAuthenticated) { %>
<nav class="nav">
<div class="nav-item">
<a href="/" class="nav-link <%= currentPage === 'dashboard' ? 'active' : '' %>">
Dashboard
</a>
</div>
<div class="nav-item">
<a href="/worlds" class="nav-link <%= currentPage === 'worlds' ? 'active' : '' %>">
Worlds
</a>
</div>
<div class="nav-item">
<a href="/extensions" class="nav-link <%= currentPage === 'extensions' ? 'active' : '' %>">
Extensions
</a>
</div>
<div class="nav-item">
<a href="/server" class="nav-link <%= currentPage === 'server' ? 'active' : '' %>">
Server
</a>
</div>
<div class="nav-item">
<a href="/config" class="nav-link <%= currentPage === 'config' ? 'active' : '' %>">
Configuration
</a>
</div>
<div class="nav-item">
<a href="/users" class="nav-link <%= currentPage === 'users' ? 'active' : '' %>">
Users
</a>
</div>
</nav>
<% } %>
<!-- Main Content -->
<main>
<%- body %>
</main>
<!-- Footer -->
<footer style="margin-top: 2rem; padding: 2rem; text-align: center; color: var(--text-secondary); font-size: 0.875rem;">
<p><a href="https://git.medlab.host/Modpol/luhost" target="_blank" style="color: var(--primary-color);">LuHost</a> |
<a href="https://luanti.org" target="_blank" style="color: var(--primary-color);">Luanti</a> |
<a href="https://content.luanti.org" target="_blank" style="color: var(--primary-color);">ContentDB</a>
</p>
<p>A project of the <a href="https://www.colorado.edu/lab/medlab/" target="_blank" style="color: var(--primary-color);">Media Economies Design Lab</a></p>
</footer>
</div>
<!-- Socket.IO -->
<script src="/socket.io/socket.io.js"></script>
<!-- Main JavaScript -->
<script src="/static/js/main.js"></script>
<!-- Page-specific scripts -->
<% if (typeof scripts !== 'undefined') { %>
<% scripts.forEach(function(script) { %>
<script src="/static/js/<%= script %>"></script>
<% }); %>
<% } %>
<!-- Inline scripts -->
<% if (typeof inlineScript !== 'undefined') { %>
<script>
<%- inlineScript %>
</script>
<% } %>
</body>
</html>

318
views/server/index.ejs Normal file
View File

@@ -0,0 +1,318 @@
<%
const body = `
<div class="page-header">
<h2>🖥️ Server Management</h2>
<p>Monitor and control your Luanti server</p>
</div>
<div class="row">
<div class="col-md-4">
<div class="card server-status-card">
<div class="card-header">
<h3>📊 Server Status</h3>
</div>
<div class="card-body">
<div class="status-indicator" id="serverStatus">
<div class="status-light offline" id="statusLight"></div>
<span id="statusText">Loading...</span>
</div>
<div class="server-stats" id="serverStats">
<div class="stat-item">
<strong id="uptime">--</strong>
<span>Uptime</span>
</div>
<div class="stat-item">
<strong id="playerCount">--</strong>
<span>Players Online</span>
</div>
<div class="stat-item">
<strong id="memoryUsage">--</strong>
<span>Memory Usage</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>🎮 Server Controls</h3>
</div>
<div class="card-body">
<div class="control-group">
<label for="worldSelect">Choose world:</label>
<select id="worldSelect" class="form-control">
<option value="">Use server defaults (no specific world)</option>
<!-- Will be populated by JavaScript -->
</select>
</div>
<div class="server-controls">
<button id="startBtn" class="btn btn-success btn-block">
▶️ Start Server
</button>
<button id="stopBtn" class="btn btn-danger btn-block" disabled>
⏹️ Stop Server
</button>
<button id="restartBtn" class="btn btn-warning btn-block" disabled>
🔄 Restart Server
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>⚙️ Quick Actions</h3>
</div>
<div class="card-body">
<a href="/config" class="btn btn-outline-primary btn-block">
⚙️ Server Configuration
</a>
<a href="/worlds" class="btn btn-outline-secondary btn-block">
🌍 World Configuration
</a>
<a href="/extensions" class="btn btn-outline-secondary btn-block">
🧩 Manage Extensions
</a>
<button id="downloadBtn" class="btn btn-outline-info btn-block">
📁 Download Logs
</button>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3>📋 Server Console</h3>
<div>
<button id="clearBtn" class="btn btn-outline-secondary btn-sm">Clear</button>
<button id="autoScrollBtn" class="btn btn-outline-primary btn-sm">
<span id="autoScrollText">Auto-scroll: ON</span>
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="console" id="serverConsole">
<div class="console-content" id="consoleContent">
<div class="log-entry info">
<span class="timestamp">${new Date().toLocaleTimeString()}</span>
<span class="message">Console ready. Start server to see logs.</span>
</div>
</div>
</div>
<div class="console-input" id="consoleInputGroup" style="display: none;">
<div class="input-group">
<input type="text" id="consoleInput" class="form-control"
placeholder="Enter server command (e.g., /say Hello World)">
<div class="input-group-append">
<button id="sendBtn" class="btn btn-primary">Send</button>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>👥 Online Players</h3>
</div>
<div class="card-body">
<div id="playersList">
<p class="text-muted">No players online</p>
</div>
</div>
</div>
</div>
</div>
<style>
.server-status-card .card-body {
text-align: center;
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
font-size: 1.1rem;
font-weight: bold;
}
.status-light {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 0.5rem;
animation: pulse 2s infinite;
}
.status-light.online {
background: var(--success-color);
}
.status-light.offline {
background: var(--danger-color);
animation: none;
}
.status-light.starting {
background: var(--warning-color);
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.server-stats {
display: grid;
gap: 1rem;
}
.stat-item {
text-align: center;
padding: 0.75rem;
background: var(--bg-accent);
border-radius: var(--border-radius);
}
.stat-item strong {
display: block;
font-size: 1.3rem;
color: var(--primary-color);
}
.stat-item span {
font-size: 0.875rem;
color: var(--text-muted);
}
.control-group {
margin-bottom: 1rem;
}
.control-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.server-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.console {
background: #1a1a1a;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
height: 400px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
overflow: hidden;
}
.console-content {
height: 100%;
overflow-y: auto;
overflow-x: auto;
padding: 1rem;
color: #ffffff;
scrollbar-width: auto;
scrollbar-color: #666666 #2a2a2a;
}
.console-content::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.console-content::-webkit-scrollbar-track {
background: #2a2a2a;
border-radius: 6px;
}
.console-content::-webkit-scrollbar-thumb {
background: #666666;
border-radius: 6px;
border: 2px solid #2a2a2a;
}
.console-content::-webkit-scrollbar-thumb:hover {
background: #888888;
}
.console-content::-webkit-scrollbar-corner {
background: #2a2a2a;
}
.log-entry {
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.log-entry.stdout {
color: #ffffff;
}
.log-entry.stderr {
color: #ff6b6b;
}
.log-entry.info {
color: #74c0fc;
}
.log-entry.warning {
color: #ffd43b;
}
.log-entry.error {
color: #ff6b6b;
}
.timestamp {
color: #868e96;
margin-right: 0.5rem;
}
.console-input {
margin-top: 1rem;
}
.console-input .form-control {
background: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-primary);
}
.console-input .form-control:focus {
background: var(--bg-secondary);
border-color: var(--primary-color);
color: var(--text-primary);
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.25);
}
@media (max-width: 768px) {
.server-controls {
gap: 0.75rem;
}
.console {
height: 300px;
}
}
</style>
`;
%>
<%- include('../layout', { body: body, currentPage: 'server', title: title }) %>

114
views/users/index.ejs Normal file
View File

@@ -0,0 +1,114 @@
<%
const body = `
<div class="card">
<div class="card-header">
<h2>User Management</h2>
<a href="/users/new" class="btn btn-success">Create New User</a>
</div>
${typeof req !== 'undefined' && req.query.created ? `
<div class="alert alert-success">
User "${req.query.created}" created successfully!
</div>
` : ''}
${typeof req !== 'undefined' && req.query.deleted ? `
<div class="alert alert-info">
User deleted successfully.
</div>
` : ''}
${typeof req !== 'undefined' && req.query.error ? `
<div class="alert alert-danger">
<strong>Error:</strong> ${req.query.error}
</div>
` : ''}
<div class="alert alert-info">
<strong>Feudal Authority:</strong> Only you can create new user accounts. All users have full administrator privileges over the Luanti server.
</div>
${users.length === 0 ? `
<div class="empty-state">
<div style="font-size: 3rem; margin-bottom: 1rem;">👥</div>
<h3>No Users Found</h3>
<p>This shouldn't happen since you're logged in. Please report this issue.</p>
</div>
` : `
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${users.map(user => `
<tr>
<td>
<strong>${user.username}</strong>
${user.id === 1 ? '<span class="status" style="background: #e8f5e8; color: #2e7d32; margin-left: 0.5rem;">Founder</span>' : ''}
</td>
<td>
<small>${formatDate(user.created_at)}</small>
</td>
<td>
${user.last_login ? `<small>${formatDate(user.last_login)}</small>` : '<small style="color: var(--text-secondary);">Never</small>'}
</td>
<td>
<div class="btn-group">
${user.id !== 1 ? `
<form method="POST" action="/users/delete/${user.id}"
style="display: inline;"
onsubmit="return confirmDelete('user', '${user.username}')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
` : `
<span class="btn btn-sm btn-secondary" style="cursor: not-allowed;" title="Cannot delete founder account">Protected</span>
`}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`}
</div>
<div class="card">
<h3>Authority & Permissions</h3>
<div style="display: grid; gap: 1rem;">
<div>
<h4 style="color: var(--primary-color); margin-bottom: 0.5rem;">🏰 Feudal System</h4>
<p>This server uses an "implicit feudalism" security model:</p>
<ul style="margin-left: 1.5rem; color: var(--text-secondary);">
<li>Only existing administrators can create new accounts</li>
<li>The founder account (first user) cannot be deleted</li>
<li>All users have equal administrative privileges</li>
<li>No public registration - authority must be granted</li>
</ul>
</div>
<div>
<h4 style="color: var(--success-color); margin-bottom: 0.5rem;">👑 Administrative Powers</h4>
<p>Every user account can:</p>
<ul style="margin-left: 1.5rem; color: var(--text-secondary);">
<li>Manage worlds (create, configure, delete)</li>
<li>Install and manage mods</li>
<li>Browse and install from ContentDB</li>
<li>Control the Luanti server (start, stop, restart)</li>
<li>Modify server configuration</li>
<li>Create additional user accounts</li>
</ul>
</div>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'users', title: title }) %>

106
views/users/new.ejs Normal file
View File

@@ -0,0 +1,106 @@
<%
const body = `
<div style="max-width: 500px; margin: 2rem auto;">
<div class="card">
<div class="card-header">
<h2>Create New Administrator</h2>
<p style="color: var(--text-secondary); margin: 0;">
Grant administrative access to a new user
</p>
</div>
${typeof error !== 'undefined' ? `
<div class="alert alert-danger">
<strong>Error:</strong> ${error}
</div>
` : ''}
<div class="alert alert-info">
<strong>Authority Note:</strong> This user will have full administrative privileges over the Luanti server, including the ability to create additional accounts.
</div>
<form method="POST" action="/users/create">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label for="username">Username*</label>
<input type="text"
id="username"
name="username"
class="form-control"
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
required
pattern="[a-zA-Z0-9_-]{3,20}"
title="3-20 characters, letters, numbers, underscore, or hyphen only"
data-validate-name
autofocus
autocomplete="username">
<small style="color: var(--text-secondary);">3-20 characters, letters, numbers, underscore, or hyphen only</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="password">Password*</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
minlength="8"
autocomplete="new-password">
<small style="color: var(--text-secondary);">At least 8 characters long</small>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password*</label>
<input type="password"
id="confirmPassword"
name="confirmPassword"
class="form-control"
required
minlength="8"
autocomplete="new-password">
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
<a href="/users" class="btn btn-secondary">
Cancel
</a>
<button type="submit" class="btn btn-success">
Create Administrator
</button>
</div>
</form>
</div>
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<p>This user will be able to perform all server management tasks and create additional accounts.</p>
</div>
</div>
<script>
// Client-side password confirmation validation
document.getElementById('confirmPassword').addEventListener('input', function() {
const password = document.getElementById('password').value;
const confirmPassword = this.value;
if (password && confirmPassword) {
if (password !== confirmPassword) {
this.setCustomValidity('Passwords do not match');
} else {
this.setCustomValidity('');
}
}
});
document.getElementById('password').addEventListener('input', function() {
const confirmPassword = document.getElementById('confirmPassword');
if (confirmPassword.value) {
confirmPassword.dispatchEvent(new Event('input'));
}
});
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'users', title: title }) %>