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:
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 }) %>
|
Reference in New Issue
Block a user