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:
63
views/auth/login.ejs
Normal file
63
views/auth/login.ejs
Normal 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
116
views/auth/register.ejs
Normal 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
687
views/config/index.ejs
Normal 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
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 }) %>
|
308
views/contentdb/installed.ejs
Normal file
308
views/contentdb/installed.ejs
Normal 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 }) %>
|
22
views/contentdb/package.ejs
Normal file
22
views/contentdb/package.ejs
Normal 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 }) %>
|
22
views/contentdb/popular.ejs
Normal file
22
views/contentdb/popular.ejs
Normal 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 }) %>
|
22
views/contentdb/recent.ejs
Normal file
22
views/contentdb/recent.ejs
Normal 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
305
views/contentdb/updates.ejs
Normal 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
142
views/dashboard.ejs
Normal 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
41
views/error.ejs
Normal 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
693
views/extensions/index.ejs
Normal 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
109
views/layout.ejs
Normal 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
318
views/server/index.ejs
Normal 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
114
views/users/index.ejs
Normal 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
106
views/users/new.ejs
Normal 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 }) %>
|
Reference in New Issue
Block a user