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