Initial commit: LuHost - Luanti Server Management Web Interface

A modern web interface for Luanti (Minetest) server management with ContentDB integration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nathan Schneider
2025-08-23 17:32:37 -06:00
commit 3aed09b60f
47 changed files with 12878 additions and 0 deletions

429
views/contentdb/index.ejs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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

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