Compare commits

...

3 Commits

Author SHA1 Message Date
Nathan Schneider
def0a66028 Generalize game ID normalization for all _game suffixes
Previously the system only handled the specific case of minetest_game -> minetest.
Based on Luanti developer feedback that "_game is removed from IDs as part of
normalisation for all game IDs not just MTG", this change generalizes the pattern.

## Changes Made

### Enhanced Game ID Normalization
- `mapToActualGameId()`: Now automatically removes "_game" suffix from any game ID
- `mapGameIdForWorldCreation()`: Generalizes suffix removal for world.mt files
- `mapInternalGameIdToDirectory()`: Enhanced to dynamically check filesystem for directories

### Backwards Compatibility
- All existing minetest_game -> minetest mappings continue to work
- Now also handles any other games with _game suffix (e.g., survival_game -> survival)
- Original EJS templates already compatible via world.gameTitle || world.gameid pattern

### Technical Implementation
- Replaces hardcoded mapping tables with general suffix detection
- Maintains proper fallback behavior for games without _game suffix
- Filesystem-aware directory resolution for reverse mapping

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 11:26:31 -06:00
Nathan Schneider
88ebb4c603 Add missing views/worlds/index.ejs and fix gitignore patterns
- Add views/worlds/index.ejs template file to repository
- Update .gitignore to use /worlds/ instead of worlds/ to only exclude root-level worlds directory
- This ensures application view templates are tracked while site-specific Luanti data remains ignored

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 11:19:23 -06:00
Nathan Schneider
c1a8784cad Add essential view templates for mods and worlds functionality
- views/mods/details.ejs: Detailed mod information and management page
- views/mods/index.ejs: Main mod management interface with world selection
- views/worlds/details.ejs: World configuration and settings page
- views/worlds/new.ejs: New world creation form

These templates were previously ignored but are required for core functionality.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 11:05:50 -06:00
7 changed files with 820 additions and 17 deletions

2
.gitignore vendored
View File

@@ -59,7 +59,7 @@ temp/
*.tmp *.tmp
# Luanti/Minetest specific files (if running locally) # Luanti/Minetest specific files (if running locally)
worlds/ /worlds/
mods/ mods/
games/ games/
textures/ textures/

View File

@@ -182,33 +182,72 @@ class LuantiPaths {
mapToActualGameId(directoryName) { mapToActualGameId(directoryName) {
// Map directory names to the actual game IDs that Luanti recognizes // Map directory names to the actual game IDs that Luanti recognizes
// For most cases, the directory name IS the game ID // Luanti normalizes game IDs by removing "_game" suffix from all games
const gameIdMap = { // See https://github.com/luanti-org/luanti/blob/c9d4c33174c87ede1f49c5fe5e8e49a784798eb6/src/content/subgames.cpp#L21
// Luanti internal alias mapping - see https://github.com/luanti-org/luanti/blob/c9d4c33174c87ede1f49c5fe5e8e49a784798eb6/src/content/subgames.cpp#L21
'minetest_game': 'minetest',
};
return gameIdMap[directoryName] || directoryName; // Remove "_game" suffix if present
if (directoryName.endsWith('_game')) {
return directoryName.slice(0, -5); // Remove last 5 characters ("_game")
}
return directoryName;
} }
mapGameIdForWorldCreation(gameId) { mapGameIdForWorldCreation(gameId) {
// When creating worlds, map game IDs that Luanti expects to use different IDs internally // When creating worlds, map game IDs that Luanti expects to use different IDs internally
// This is the reverse of directory-based detection - we're setting what goes in world.mt // This is the reverse of directory-based detection - we're setting what goes in world.mt
const worldGameIdMap = { // Luanti normalizes all game IDs by removing "_game" suffix
'minetest_game': 'minetest', // Luanti expects 'minetest' in world.mt even for minetest_game
};
return worldGameIdMap[gameId] || gameId; // Remove "_game" suffix if present
if (gameId.endsWith('_game')) {
return gameId.slice(0, -5); // Remove last 5 characters ("_game")
}
return gameId;
} }
mapInternalGameIdToDirectory(internalGameId) { async mapInternalGameIdToDirectory(internalGameId) {
// Reverse mapping: convert internal game ID back to directory name for display/reference // Reverse mapping: convert internal game ID back to directory name for display/reference
// This helps when we read a world.mt with "minetest" but want to show "Minetest Game" // This helps when we read a world.mt with normalized game ID but want to show directory name
const reverseGameIdMap = { // Since Luanti normalizes by removing "_game" suffix, we need to check if a directory
'minetest': 'minetest_game', // world.mt has 'minetest' but directory is 'minetest_game' // with "_game" suffix exists for this normalized ID
};
return reverseGameIdMap[internalGameId] || internalGameId; const fs = require('fs').promises;
const path = require('path');
// Check both system and user games directories
const gameDirs = [this.getGamesPath()];
if (this.getSystemGamesPath() && this.getSystemGamesPath() !== this.getGamesPath()) {
gameDirs.push(this.getSystemGamesPath());
}
for (const gameDir of gameDirs) {
try {
// First check if the internalGameId as-is exists as a directory
const directPath = path.join(gameDir, internalGameId);
const stat = await fs.stat(directPath);
if (stat.isDirectory()) {
return internalGameId;
}
} catch (error) {
// Directory doesn't exist, try with "_game" suffix
}
try {
// Check if there's a directory with "_game" suffix
const gameIdWithSuffix = internalGameId + '_game';
const suffixPath = path.join(gameDir, gameIdWithSuffix);
const stat = await fs.stat(suffixPath);
if (stat.isDirectory()) {
return gameIdWithSuffix;
}
} catch (error) {
// Directory doesn't exist either
}
}
// Fallback: return the original ID
return internalGameId;
} }
async getInstalledGames() { async getInstalledGames() {

150
views/mods/details.ejs Normal file
View File

@@ -0,0 +1,150 @@
<%
const body = `
<div class="page-header">
<div class="breadcrumb">
<a href="/mods">Mods</a>
<span>${mod.title}</span>
</div>
<h2>${mod.title}</h2>
${mod.description ? `<p class="text-secondary">${mod.description}</p>` : ''}
</div>
<div class="grid">
<div class="card">
<div class="card-header">
<h3>Mod Information</h3>
</div>
<div class="card-body">
<div class="info-grid">
<div><strong>Name:</strong> ${mod.name}</div>
<div><strong>Author:</strong> ${mod.author || 'Unknown'}</div>
<div><strong>Files:</strong> ${mod.fileCount} files</div>
<div><strong>Size:</strong> ${(mod.totalSize / 1024).toFixed(2)} KB</div>
<div><strong>Created:</strong> ${new Date(mod.created).toLocaleString()}</div>
<div><strong>Modified:</strong> ${new Date(mod.lastModified).toLocaleString()}</div>
${mod.min_minetest_version ? `<div><strong>Min Version:</strong> ${mod.min_minetest_version}</div>` : ''}
${mod.max_minetest_version ? `<div><strong>Max Version:</strong> ${mod.max_minetest_version}</div>` : ''}
</div>
${mod.depends.length > 0 ? `
<div style="margin-top: 16px;">
<strong>Dependencies:</strong>
<div class="tag-list">
${mod.depends.map(dep => `<span class="tag tag-required">${dep}</span>`).join('')}
</div>
</div>
` : ''}
${mod.optional_depends.length > 0 ? `
<div style="margin-top: 16px;">
<strong>Optional Dependencies:</strong>
<div class="tag-list">
${mod.optional_depends.map(dep => `<span class="tag tag-optional">${dep}</span>`).join('')}
</div>
</div>
` : ''}
</div>
</div>
${mod.installedWorlds.length > 0 ? `
<div class="card">
<div class="card-header">
<h3>Installed in Worlds (${mod.installedWorlds.length})</h3>
</div>
<div class="card-body">
<div class="world-list">
${mod.installedWorlds.map(world => `
<div class="world-item">
<div>
<strong>${world.displayName}</strong>
<span class="text-secondary">(${world.name})</span>
</div>
<div class="world-actions">
<form method="POST" action="/mods/remove/${world.name}/${mod.name}" style="display: inline;" onsubmit="return confirm('Remove this mod from ${world.displayName}?')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
</form>
</div>
</div>
`).join('')}
</div>
</div>
</div>
` : ''}
<div class="card">
<div class="card-header">
<h3>Actions</h3>
</div>
<div class="card-body">
<div class="action-buttons">
<a href="/mods" class="btn btn-outline">← Back to Mods</a>
<form method="POST" action="/mods/delete/${mod.name}" style="display: inline;" onsubmit="return confirm('Permanently delete this mod and remove it from all worlds?')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-danger">Delete Mod</button>
</form>
</div>
</div>
</div>
</div>
<style>
.info-grid {
display: grid;
gap: 8px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.tag {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
}
.tag-required {
background: var(--error-color);
color: white;
}
.tag-optional {
background: var(--warning-color);
color: white;
}
.world-list {
display: grid;
gap: 12px;
}
.world-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
}
.world-actions {
display: flex;
gap: 8px;
}
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
</style>
`;
%>
<%- include('../layout', { body: body, currentPage: 'mods', title: title }) %>

275
views/mods/index.ejs Normal file
View File

@@ -0,0 +1,275 @@
<%
const body = `
<div class="page-header">
<h2>Mod Management</h2>
<p class="text-secondary">Install and manage mods for your worlds</p>
</div>
${typeof req !== 'undefined' && req.query && req.query.enabled ? `
<div class="alert alert-success">
Mod "${req.query.enabled}" enabled for ${selectedWorld}!
</div>
` : ''}
${typeof req !== 'undefined' && req.query && req.query.disabled ? `
<div class="alert alert-success">
Mod "${req.query.disabled}" disabled for ${selectedWorld}!
</div>
` : ''}
${typeof req !== 'undefined' && req.query && req.query.copied ? `
<div class="alert alert-success">
Mod "${req.query.copied}" copied to ${selectedWorld}!
</div>
` : ''}
${typeof req !== 'undefined' && req.query && req.query.removed ? `
<div class="alert alert-success">
Mod "${req.query.removed}" removed from ${selectedWorld}!
</div>
` : ''}
${typeof req !== 'undefined' && req.query && req.query.deleted ? `
<div class="alert alert-success">
Mod "${req.query.deleted}" deleted permanently!
</div>
` : ''}
<div class="card">
<div class="card-header">
<h3>Select World</h3>
</div>
<div class="card-body">
<form method="GET">
<div style="display: flex; gap: 12px; align-items: end;">
<div style="flex: 1;">
<label>World</label>
<select name="world" class="form-control">
<option value="">Select a world...</option>
${worlds.map(world => `
<option value="${world.name}" ${selectedWorld === world.name ? 'selected' : ''}>
${world.displayName}
</option>
`).join('')}
</select>
</div>
<button type="submit" class="btn btn-primary">View Mods</button>
</div>
</form>
</div>
</div>
${selectedWorld ? `
<div class="grid">
<div class="card">
<div class="card-header">
<h3>World Mods (${worldMods.length})</h3>
<p class="text-secondary">Mods installed specifically for ${selectedWorld}</p>
</div>
<div class="card-body">
${worldMods.length > 0 ? `
<div class="mod-grid">
${worldMods.map(mod => `
<div class="mod-card">
<div class="mod-header">
<h4>${mod.title}</h4>
${mod.location === 'global-enabled' ? `
<span class="badge badge-success">Enabled</span>
` : mod.location === 'world-installed' ? `
<span class="badge badge-primary">World Copy</span>
` : mod.location === 'global-missing' ? `
<span class="badge badge-danger">Missing</span>
` : `
<span class="badge badge-secondary">Unknown</span>
`}
</div>
${mod.description ? `<p class="mod-description">${mod.description}</p>` : ''}
<div class="mod-meta">
${mod.author ? `<span><strong>Author:</strong> ${mod.author}</span>` : ''}
${mod.depends && mod.depends.length > 0 ? `<span><strong>Depends:</strong> ${mod.depends.join(', ')}</span>` : ''}
<span><strong>Type:</strong> ${
mod.location === 'global-enabled' ? 'Global mod (enabled via config)' :
mod.location === 'world-installed' ? 'World-specific installation' :
mod.location === 'global-missing' ? 'Missing global mod' : 'Unknown'
}</span>
</div>
<div class="mod-actions">
${mod.location === 'global-enabled' ? `
<form method="POST" action="/mods/disable/${selectedWorld}/${mod.name}" style="display: inline;" onsubmit="return confirm('Disable this mod for the world?')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-sm btn-warning">Disable</button>
</form>
` : mod.location === 'world-installed' ? `
<form method="POST" action="/mods/remove/${selectedWorld}/${mod.name}" style="display: inline;" onsubmit="return confirm('Delete this mod copy from the world?')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-sm btn-danger">Delete Copy</button>
</form>
` : mod.location === 'global-missing' ? `
<span class="text-danger">Missing from global mods</span>
` : ''}
</div>
</div>
`).join('')}
</div>
` : `
<p class="text-secondary">No mods enabled for this world.</p>
`}
</div>
</div>
<div class="card">
<div class="card-header">
<h3>Available Global Mods (${globalMods.length})</h3>
<p class="text-secondary">Install these mods to ${selectedWorld}</p>
</div>
<div class="card-body">
${globalMods.length > 0 ? `
<div class="mod-grid">
${globalMods.map(mod => {
const isEnabled = worldMods.some(wm => wm.name === mod.name && wm.location === 'global-enabled');
const isCopied = worldMods.some(wm => wm.name === mod.name && wm.location === 'world-installed');
const isInUse = isEnabled || isCopied;
return `
<div class="mod-card ${isInUse ? 'mod-installed' : ''}">
<div class="mod-header">
<h4>${mod.title}</h4>
<span class="badge ${isInUse ? 'badge-success' : 'badge-secondary'}">
${isEnabled ? 'Enabled' : isCopied ? 'Copied' : 'Available'}
</span>
</div>
${mod.description ? `<p class="mod-description">${mod.description}</p>` : ''}
<div class="mod-meta">
${mod.author ? `<span><strong>Author:</strong> ${mod.author}</span>` : ''}
${mod.depends && mod.depends.length > 0 ? `<span><strong>Depends:</strong> ${mod.depends.join(', ')}</span>` : ''}
</div>
<div class="mod-actions">
${!isEnabled ? `
<form method="POST" action="/mods/enable/${selectedWorld}/${mod.name}" style="display: inline;">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-sm btn-primary">Enable</button>
</form>
` : ''}
${!isCopied ? `
<form method="POST" action="/mods/copy/${selectedWorld}/${mod.name}" style="display: inline;">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-sm btn-secondary">Copy to World</button>
</form>
` : ''}
<a href="/mods/${mod.name}" class="btn btn-sm btn-outline">Details</a>
</div>
</div>
`;
}).join('')}
</div>
` : `
<p class="text-secondary">No global mods found. Upload mods to the mods directory to see them here.</p>
`}
</div>
</div>
</div>
` : `
<div class="card">
<div class="card-header">
<h3>Global Mods (${globalMods.length})</h3>
</div>
<div class="card-body">
${globalMods.length > 0 ? `
<div class="mod-grid">
${globalMods.map(mod => `
<div class="mod-card">
<div class="mod-header">
<h4>${mod.title}</h4>
<span class="badge badge-secondary">Global</span>
</div>
${mod.description ? `<p class="mod-description">${mod.description}</p>` : ''}
<div class="mod-meta">
${mod.author ? `<span><strong>Author:</strong> ${mod.author}</span>` : ''}
${mod.depends.length > 0 ? `<span><strong>Depends:</strong> ${mod.depends.join(', ')}</span>` : ''}
</div>
<div class="mod-actions">
<a href="/mods/${mod.name}" class="btn btn-sm btn-outline">Details</a>
<form method="POST" action="/mods/delete/${mod.name}" style="display: inline;" onsubmit="return confirm('Permanently delete this mod?')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</div>
`).join('')}
</div>
` : `
<p class="text-secondary">No global mods found. Upload mods to the mods directory to see them here.</p>
`}
</div>
</div>
`}
<style>
.mod-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.mod-card {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
background: var(--card-bg);
}
.mod-card.mod-installed {
border-color: var(--success-color);
background: var(--success-bg);
}
.mod-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.mod-header h4 {
margin: 0;
font-size: 16px;
}
.mod-description {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 12px;
line-height: 1.4;
}
.mod-meta {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.mod-meta span {
display: block;
margin-bottom: 4px;
}
.mod-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.badge {
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
}
.badge-primary { background: var(--primary-color); color: white; }
.badge-secondary { background: var(--text-secondary); color: white; }
.badge-success { background: var(--success-color); color: white; }
</style>
`;
%>
<%- include('../layout', { body: body, currentPage: 'mods', title: title }) %>

131
views/worlds/details.ejs Normal file
View File

@@ -0,0 +1,131 @@
<%
const body = `
<div class="page-header">
<div class="breadcrumb">
<a href="/worlds">Worlds</a>
<span>${world.displayName}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<h2>${world.displayName}</h2>
<form method="POST" action="/worlds/${world.name}/delete"
style="display: inline;"
onsubmit="return confirmDelete('world', '${world.displayName}')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-danger">Delete World</button>
</form>
</div>
${world.description ? `<p class="text-secondary">${world.description}</p>` : ''}
</div>
${typeof req !== 'undefined' && req.query && req.query.updated ? `
<div class="alert alert-success">
World settings updated successfully!
</div>
` : ''}
<div class="grid">
<div class="card">
<div class="card-header">
<h3>World Settings</h3>
</div>
<div class="card-body">
<form method="POST" action="/worlds/${world.name}/update">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label>Display Name</label>
<input
type="text"
name="displayName"
class="form-control"
value="${world.displayName}"
/>
</div>
<div class="form-group">
<label>Description</label>
<textarea
name="description"
class="form-control"
rows="3"
>${world.description || ''}</textarea>
</div>
<div class="form-group">
<label>Game Settings</label>
<div style="display: grid; gap: 8px; margin-top: 8px;">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="creativeMode" ${world.creativeMode ? 'checked' : ''} />
<span>Enable Creative Mode</span>
</label>
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="enableDamage" ${world.enableDamage ? 'checked' : ''} />
<span>Enable Damage</span>
</label>
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="enablePvp" ${world.enablePvp ? 'checked' : ''} />
<span>Enable PvP</span>
</label>
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="serverAnnounce" ${world.serverAnnounce ? 'checked' : ''} />
<span>Announce Server</span>
</label>
</div>
</div>
<div style="display: flex; gap: 12px; margin-top: 24px;">
<button type="submit" class="btn btn-primary">Update Settings</button>
<a href="/mods?world=${world.name}" class="btn btn-outline">Manage Mods</a>
</div>
</form>
<div style="margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-color);">
<h4>World Information</h4>
<div style="display: grid; gap: 8px; margin-top: 12px;">
<div><strong>Internal Name:</strong> ${world.name}</div>
<div><strong>Game:</strong> ${world.gameid}</div>
<div><strong>World Size:</strong> ${(world.worldSize / 1024 / 1024).toFixed(2)} MB</div>
<div><strong>Created:</strong> ${new Date(world.created).toLocaleString()}</div>
<div><strong>Last Modified:</strong> ${new Date(world.lastModified).toLocaleString()}</div>
</div>
</div>
</div>
</div>
${world.enabledMods && world.enabledMods.length > 0 ? `
<div class="card">
<div class="card-header">
<h3>Enabled Mods (${world.enabledMods.length})</h3>
</div>
<div class="card-body">
<div class="mod-list">
${world.enabledMods.map(mod => `
<div class="mod-item">
<div class="mod-info">
<strong>${mod.title}</strong>
${mod.author ? `<span class="text-secondary">by ${mod.author}</span>` : ''}
</div>
${mod.description ? `<div class="mod-description">${mod.description}</div>` : ''}
<div class="mod-location">
${mod.location === 'global-enabled' ? `
<span class="badge badge-success">Global (Enabled)</span>
` : mod.location === 'world-installed' ? `
<span class="badge badge-primary">World Copy</span>
` : mod.location === 'global-missing' ? `
<span class="badge badge-danger">Missing</span>
` : `
<span class="badge badge-secondary">${mod.location}</span>
`}
</div>
</div>
`).join('')}
</div>
</div>
</div>
` : ''}
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'worlds', title: title }) %>

138
views/worlds/index.ejs Normal file
View File

@@ -0,0 +1,138 @@
<%
const body = `
<div class="page-header">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px;">
<div>
<h2>Worlds Management</h2>
<p class="text-secondary">Manage your Luanti worlds</p>
</div>
<a href="/worlds/new" class="btn btn-primary">Create New World</a>
</div>
</div>
<div id="creatingAlert" class="alert alert-info" style="display: none;">
<strong>Creating world...</strong><br>
<span id="creatingWorldName"></span> is being created.
<div style="margin-top: 8px;">
<div style="width: 100%; height: 6px; background: rgba(255,255,255,0.3); border-radius: 3px;">
<div id="progressBar" style="height: 100%; background: var(--text-light); border-radius: 3px; width: 0%; transition: width 0.3s;"></div>
</div>
</div>
</div>
${typeof worlds !== 'undefined' && worlds.length > 0 ? `
<div class="grid">
${worlds.map(world => `
<div class="card">
<div class="card-header">
<h3>${world.displayName}</h3>
<span class="badge ${world.creativeMode ? 'badge-info' : 'badge-success'}">
${world.creativeMode ? 'Creative' : 'Survival'}
</span>
</div>
<div class="card-body">
${world.description ? `<p class="text-secondary">${world.description}</p>` : ''}
<div class="details">
<div class="detail-item">
<strong>Game:</strong> ${world.gameTitle || world.gameid}
</div>
<div class="detail-item">
<strong>Players:</strong> ${world.playerCount || 0}
</div>
<div class="detail-item">
<strong>PvP:</strong> ${world.enablePvp ? 'Enabled' : 'Disabled'}
</div>
<div class="detail-item">
<strong>Damage:</strong> ${world.enableDamage ? 'Enabled' : 'Disabled'}
</div>
<div class="detail-item">
<strong>Last Modified:</strong> ${new Date(world.lastModified).toLocaleDateString()}
</div>
</div>
</div>
<div class="card-footer">
<a href="/worlds/${world.name}" class="btn btn-primary btn-sm">View Details</a>
<form method="POST" action="/worlds/${world.name}/delete"
style="display: inline;"
onsubmit="return confirmDelete('world', '${world.displayName}')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
`).join('')}
</div>
` : `
<div class="empty-state">
<h3>No worlds created yet</h3>
<p>Create your first world to get started with hosting Luanti servers.</p>
<a href="/worlds/new" class="btn btn-primary">Create First World</a>
</div>
`}
`;
%>
<%- include('../layout', {
body: body,
currentPage: 'worlds',
title: title,
inlineScript: `
// Handle world creation progress
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const creatingWorldName = urlParams.get('creating');
if (creatingWorldName) {
// Show creating alert
const creatingAlert = document.getElementById('creatingAlert');
const creatingWorldNameSpan = document.getElementById('creatingWorldName');
const progressBar = document.getElementById('progressBar');
if (creatingAlert && creatingWorldNameSpan) {
creatingWorldNameSpan.textContent = creatingWorldName;
creatingAlert.style.display = 'block';
// Quick progress animation (since creation is fast but not instant)
let progress = 0;
const progressInterval = setInterval(() => {
progress += 15;
if (progress > 85) progress = 85; // Don't complete until we get websocket confirmation
progressBar.style.width = progress + '%';
}, 100);
// Listen for websocket events
if (typeof socket !== 'undefined') {
socket.on('worldCreated', function(data) {
if (data.worldName === creatingWorldName) {
clearInterval(progressInterval);
if (data.success) {
progressBar.style.width = '100%';
setTimeout(() => {
// Remove the creating parameter and reload
const newUrl = new URL(window.location);
newUrl.searchParams.delete('creating');
window.location.href = newUrl.toString();
}, 500);
} else {
creatingAlert.className = 'alert alert-danger';
creatingAlert.innerHTML = '<strong>World creation failed:</strong><br>' +
(data.error || 'Unknown error occurred');
}
}
});
}
// Fallback: reload after 5 seconds if no websocket response
setTimeout(() => {
clearInterval(progressInterval);
const newUrl = new URL(window.location);
newUrl.searchParams.delete('creating');
window.location.href = newUrl.toString();
}, 5000);
}
}
});
`
}) %>

70
views/worlds/new.ejs Normal file
View File

@@ -0,0 +1,70 @@
<%
const body = `
<div class="page-header">
<div style="margin-bottom: 16px;">
<a href="/worlds" style="color: var(--primary-color); text-decoration: none;">&larr; Back to Worlds</a>
</div>
<h2>Create New World</h2>
<p>Create a new Luanti world</p>
</div>
${typeof error !== 'undefined' && error ? `
<div class="alert alert-danger">
${error}
</div>
` : ''}
<div class="card">
<form method="POST" action="/worlds/create">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label for="name">World Name *</label>
<input
type="text"
id="name"
name="name"
class="form-control"
required
pattern="[a-zA-Z0-9_-]+"
title="Only letters, numbers, underscore and hyphen allowed"
value="${typeof formData !== 'undefined' && formData && formData.name ? formData.name : ''}"
/>
<small style="color: var(--text-secondary);">Only letters, numbers, underscore and hyphen allowed</small>
</div>
<div class="form-group">
<label for="gameid">Game Type *</label>
<select id="gameid" name="gameid" class="form-control" required style="max-width: 300px;">
${typeof games !== 'undefined' && games.length > 0 ? games.map((game, index) => `
<option
value="${game.name}"
${typeof formData !== 'undefined' && formData && formData.gameid === game.name ? 'selected' : (index === 0 && (typeof formData === 'undefined' || !formData || !formData.gameid) ? 'selected' : '')}
>
${game.title || game.name}
</option>
`).join('') : `
<option value="minetest_game" selected>Minetest Game</option>
<option value="minimal">Minimal</option>
`}
</select>
<small style="color: var(--text-secondary);">Choose the base game for your world</small>
</div>
<div style="display: flex; gap: 12px; margin-top: 24px;">
<button type="submit" class="btn btn-primary">Create World</button>
<a href="/worlds" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="alert alert-info" style="margin-top: 24px;">
<strong>World Creation Notes:</strong><br>
• World creation may take a few moments to complete<br>
• You will be redirected to the worlds list when creation starts<br>
• Additional world settings can be configured after creation<br>
• Make sure the selected game is properly installed
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'worlds', title: title }) %>