Compare commits
2 Commits
2d3b1166fe
...
09c4f93ec9
Author | SHA1 | Date | |
---|---|---|---|
|
09c4f93ec9 | ||
|
8aadab1b50 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -50,6 +50,9 @@ Thumbs.db
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Claude Code specific files (keep settings for future development)
|
||||
# .claude/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
@@ -75,8 +78,8 @@ backups/
|
||||
*.crt
|
||||
*.csr
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
# Lock files (keep package-lock.json for consistent installs)
|
||||
# package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
8
app.js
8
app.js
@@ -204,6 +204,14 @@ app.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Verify and clean package registry
|
||||
try {
|
||||
const packageRegistry = require('./utils/package-registry');
|
||||
await packageRegistry.verifyIfNeeded();
|
||||
} catch (registryError) {
|
||||
console.warn('Registry verification failed on dashboard load:', registryError);
|
||||
}
|
||||
|
||||
// Get basic stats for dashboard
|
||||
const fs = require('fs').promises;
|
||||
let worldCount = 0;
|
||||
|
3707
package-lock.json
generated
Normal file
3707
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -144,18 +144,22 @@ body {
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 16px 20px;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: clamp(0.75rem, 1.2vw, 0.9rem);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: all 0.1s ease;
|
||||
border-right: var(--border-width) solid var(--border-dark);
|
||||
background: linear-gradient(180deg,
|
||||
@@ -679,14 +683,11 @@ body {
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.nav-item {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.nav-link {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.85rem;
|
||||
padding: 12px 8px;
|
||||
font-size: clamp(0.7rem, 1.1vw, 0.85rem);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -280,6 +280,13 @@ router.get('/updates', async (req, res) => {
|
||||
// View installed packages
|
||||
router.get('/installed', async (req, res) => {
|
||||
try {
|
||||
// Verify and clean registry before showing installed packages
|
||||
try {
|
||||
await packageRegistry.verifyIfNeeded();
|
||||
} catch (verifyError) {
|
||||
console.warn('Registry verification failed:', verifyError);
|
||||
}
|
||||
|
||||
const { location } = req.query;
|
||||
const packages = await packageRegistry.getInstalledPackages(location);
|
||||
const stats = await packageRegistry.getStatistics();
|
||||
@@ -384,6 +391,14 @@ router.post('/install-url', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify registry and clean up stale entries before checking installation status
|
||||
try {
|
||||
await packageRegistry.verifyIfNeeded();
|
||||
} catch (verifyError) {
|
||||
console.warn('Registry verification failed:', verifyError);
|
||||
// Continue anyway - don't block installation
|
||||
}
|
||||
|
||||
// Check if already installed at this location
|
||||
let installLocationKey;
|
||||
if (packageType === 'game') {
|
||||
|
@@ -121,8 +121,52 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Install mod to world
|
||||
router.post('/install/:worldName/:modName', async (req, res) => {
|
||||
// Enable mod for world (configuration-based)
|
||||
router.post('/enable/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
||||
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
||||
return res.status(400).json({ error: 'Invalid world or mod name' });
|
||||
}
|
||||
|
||||
const worldPath = paths.getWorldPath(worldName);
|
||||
const worldConfigPath = paths.getWorldConfigPath(worldName);
|
||||
const globalModPath = paths.getModPath(modName);
|
||||
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'World not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(globalModPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Mod not found' });
|
||||
}
|
||||
|
||||
// Read current world configuration
|
||||
const config = await ConfigParser.parseWorldConfig(worldConfigPath);
|
||||
|
||||
// Enable the mod
|
||||
if (!config.enabled_mods) {
|
||||
config.enabled_mods = {};
|
||||
}
|
||||
config.enabled_mods[modName] = true;
|
||||
|
||||
// Write updated configuration
|
||||
await ConfigParser.writeWorldConfig(worldConfigPath, config);
|
||||
|
||||
res.redirect(`/mods?world=${worldName}&enabled=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error enabling mod for world:', error);
|
||||
res.status(500).json({ error: 'Failed to enable mod for world' });
|
||||
}
|
||||
});
|
||||
|
||||
// Copy mod to world (physical installation)
|
||||
router.post('/copy/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
||||
@@ -149,20 +193,49 @@ router.post('/install/:worldName/:modName', async (req, res) => {
|
||||
|
||||
try {
|
||||
await fs.access(targetModPath);
|
||||
return res.status(409).json({ error: 'Mod already installed in world' });
|
||||
return res.status(409).json({ error: 'Mod already copied to world' });
|
||||
} catch {}
|
||||
|
||||
await fs.mkdir(worldModsPath, { recursive: true });
|
||||
await fs.cp(globalModPath, targetModPath, { recursive: true });
|
||||
|
||||
res.redirect(`/mods?world=${worldName}&installed=${modName}`);
|
||||
res.redirect(`/mods?world=${worldName}&copied=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error installing mod to world:', error);
|
||||
res.status(500).json({ error: 'Failed to install mod to world' });
|
||||
console.error('Error copying mod to world:', error);
|
||||
res.status(500).json({ error: 'Failed to copy mod to world' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove mod from world
|
||||
// Disable mod from world (configuration-based)
|
||||
router.post('/disable/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
||||
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
||||
return res.status(400).json({ error: 'Invalid world or mod name' });
|
||||
}
|
||||
|
||||
const worldConfigPath = paths.getWorldConfigPath(worldName);
|
||||
|
||||
// Read current world configuration
|
||||
const config = await ConfigParser.parseWorldConfig(worldConfigPath);
|
||||
|
||||
// Disable the mod
|
||||
if (config.enabled_mods && config.enabled_mods[modName]) {
|
||||
config.enabled_mods[modName] = false;
|
||||
}
|
||||
|
||||
// Write updated configuration
|
||||
await ConfigParser.writeWorldConfig(worldConfigPath, config);
|
||||
|
||||
res.redirect(`/mods?world=${worldName}&disabled=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error disabling mod for world:', error);
|
||||
res.status(500).json({ error: 'Failed to disable mod for world' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove mod from world (delete physical copy)
|
||||
router.post('/remove/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
@@ -33,11 +33,17 @@ router.get('/', async (req, res) => {
|
||||
let playerCount = 0;
|
||||
try {
|
||||
const playersDbPath = path.join(worldPath, 'players.sqlite');
|
||||
const db = new sqlite3.Database(playersDbPath);
|
||||
const all = promisify(db.all.bind(db));
|
||||
const result = await all('SELECT COUNT(*) as count FROM players');
|
||||
playerCount = result[0]?.count || 0;
|
||||
db.close();
|
||||
// Only try to read if the database file already exists and has content
|
||||
if (fs.existsSync(playersDbPath)) {
|
||||
const stats = fs.statSync(playersDbPath);
|
||||
if (stats.size > 0) {
|
||||
const db = new sqlite3.Database(playersDbPath, sqlite3.OPEN_READONLY);
|
||||
const all = promisify(db.all.bind(db));
|
||||
const result = await all('SELECT COUNT(*) as count FROM players');
|
||||
playerCount = result[0]?.count || 0;
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
} catch (dbError) {}
|
||||
|
||||
const gameid = config.gameid || 'minetest_game';
|
||||
@@ -131,18 +137,29 @@ router.post('/create', async (req, res) => {
|
||||
});
|
||||
} catch {}
|
||||
|
||||
console.log('Starting world creation for:', name, 'with gameid:', gameid);
|
||||
console.log('Creating world:', name, 'with gameid:', gameid);
|
||||
|
||||
// Start world creation asynchronously and respond immediately
|
||||
serverManager.createWorld(name, gameid || 'minetest_game')
|
||||
// Get the default game if none specified
|
||||
let finalGameId = gameid;
|
||||
if (!finalGameId) {
|
||||
try {
|
||||
const games = await paths.getInstalledGames();
|
||||
finalGameId = games.length > 0 ? games[0].name : 'minetest_game';
|
||||
} catch (error) {
|
||||
finalGameId = 'minetest_game';
|
||||
}
|
||||
}
|
||||
|
||||
// Start world creation and redirect immediately with creating status
|
||||
serverManager.createWorld(name, finalGameId)
|
||||
.then(() => {
|
||||
console.log('Successfully created world using Luanti:', name);
|
||||
console.log('Successfully created world:', name);
|
||||
// Notify clients via WebSocket
|
||||
const io = req.app.get('socketio');
|
||||
if (io) {
|
||||
io.emit('worldCreated', {
|
||||
worldName: name,
|
||||
gameId: gameid || 'minetest_game',
|
||||
gameId: finalGameId,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
@@ -154,7 +171,7 @@ router.post('/create', async (req, res) => {
|
||||
if (io) {
|
||||
io.emit('worldCreated', {
|
||||
worldName: name,
|
||||
gameId: gameid || 'minetest_game',
|
||||
gameId: finalGameId,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
@@ -207,6 +224,40 @@ router.get('/:worldName', async (req, res) => {
|
||||
} catch {}
|
||||
|
||||
let enabledMods = [];
|
||||
|
||||
// Get configuration-enabled mods (from global mods directory)
|
||||
if (config.enabled_mods) {
|
||||
for (const [modName, enabled] of Object.entries(config.enabled_mods)) {
|
||||
if (enabled) {
|
||||
try {
|
||||
const globalModPath = paths.getModPath(modName);
|
||||
const globalModConfigPath = paths.getModConfigPath(modName);
|
||||
await fs.access(globalModPath);
|
||||
const modConfig = await ConfigParser.parseModConfig(globalModConfigPath);
|
||||
enabledMods.push({
|
||||
name: modName,
|
||||
title: modConfig.title || modName,
|
||||
description: modConfig.description || '',
|
||||
author: modConfig.author || '',
|
||||
location: 'global-enabled',
|
||||
path: globalModPath
|
||||
});
|
||||
} catch {
|
||||
// Global mod not found, but still enabled in config - show as missing
|
||||
enabledMods.push({
|
||||
name: modName,
|
||||
title: modName,
|
||||
description: 'Missing global mod',
|
||||
author: '',
|
||||
location: 'global-missing',
|
||||
path: null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get physically installed world mods
|
||||
try {
|
||||
const worldModsPath = paths.getWorldModsPath(worldName);
|
||||
const modDirs = await fs.readdir(worldModsPath);
|
||||
@@ -219,7 +270,8 @@ router.get('/:worldName', async (req, res) => {
|
||||
title: modConfig.title || modDir,
|
||||
description: modConfig.description || '',
|
||||
author: modConfig.author || '',
|
||||
location: 'world'
|
||||
location: 'world-installed',
|
||||
path: path.join(worldModsPath, modDir)
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
@@ -85,6 +85,15 @@ class ConfigParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mod configurations
|
||||
config.enabled_mods = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key.startsWith('load_mod_')) {
|
||||
const modName = key.replace('load_mod_', '');
|
||||
config.enabled_mods[modName] = value === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -98,6 +107,14 @@ class ConfigParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mod configurations
|
||||
if (configCopy.enabled_mods) {
|
||||
for (const [modName, enabled] of Object.entries(configCopy.enabled_mods)) {
|
||||
configCopy[`load_mod_${modName}`] = enabled.toString();
|
||||
}
|
||||
delete configCopy.enabled_mods; // Remove the helper object
|
||||
}
|
||||
|
||||
await this.writeConfig(filePath, configCopy);
|
||||
}
|
||||
|
||||
|
@@ -7,6 +7,8 @@ class PackageRegistry {
|
||||
// If no dbPath provided, we'll set it during init based on current data directory
|
||||
this.dbPath = dbPath;
|
||||
this.db = null;
|
||||
this.lastVerificationTime = 0;
|
||||
this.verificationInterval = 5 * 60 * 1000; // 5 minutes
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -277,6 +279,83 @@ class PackageRegistry {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async verifyAndCleanRegistry() {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
const fs = require('fs').promises;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all('SELECT * FROM installed_packages', async (err, rows) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const toRemove = [];
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
// Check if the package still exists at the recorded path
|
||||
await fs.access(row.install_path);
|
||||
|
||||
// Additional verification for games/mods - check for key files
|
||||
if (row.package_type === 'game') {
|
||||
// Check for game.conf
|
||||
const gameConfPath = require('path').join(row.install_path, 'game.conf');
|
||||
await fs.access(gameConfPath);
|
||||
} else if (row.package_type === 'mod') {
|
||||
// Check for mod.conf or init.lua
|
||||
const modConfPath = require('path').join(row.install_path, 'mod.conf');
|
||||
const initLuaPath = require('path').join(row.install_path, 'init.lua');
|
||||
try {
|
||||
await fs.access(modConfPath);
|
||||
} catch {
|
||||
await fs.access(initLuaPath);
|
||||
}
|
||||
}
|
||||
} catch (accessError) {
|
||||
// Package directory or key files don't exist - mark for removal
|
||||
console.log(`Package registry cleanup: Removing stale entry for ${row.author}/${row.name} (path not found: ${row.install_path})`);
|
||||
toRemove.push(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale entries
|
||||
if (toRemove.length > 0) {
|
||||
const placeholders = toRemove.map(() => '?').join(',');
|
||||
this.db.run(`DELETE FROM installed_packages WHERE id IN (${placeholders})`, toRemove, (deleteErr) => {
|
||||
if (deleteErr) {
|
||||
console.error('Error cleaning up registry:', deleteErr);
|
||||
reject(deleteErr);
|
||||
} else {
|
||||
console.log(`Package registry cleanup: Removed ${toRemove.length} stale entries`);
|
||||
resolve(toRemove.length);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async verifyIfNeeded() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastVerificationTime > this.verificationInterval) {
|
||||
try {
|
||||
const cleaned = await this.verifyAndCleanRegistry();
|
||||
this.lastVerificationTime = now;
|
||||
return cleaned;
|
||||
} catch (error) {
|
||||
console.warn('Periodic registry verification failed:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PackageRegistry;
|
@@ -184,13 +184,33 @@ class LuantiPaths {
|
||||
// Map directory names to the actual game IDs that Luanti recognizes
|
||||
// For most cases, the directory name IS the game ID
|
||||
const gameIdMap = {
|
||||
// Only add mappings here if you're certain they're needed
|
||||
// 'minetest_game': 'minetest', // This mapping was incorrect
|
||||
// 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;
|
||||
}
|
||||
|
||||
mapGameIdForWorldCreation(gameId) {
|
||||
// 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
|
||||
const worldGameIdMap = {
|
||||
'minetest_game': 'minetest', // Luanti expects 'minetest' in world.mt even for minetest_game
|
||||
};
|
||||
|
||||
return worldGameIdMap[gameId] || gameId;
|
||||
}
|
||||
|
||||
mapInternalGameIdToDirectory(internalGameId) {
|
||||
// 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"
|
||||
const reverseGameIdMap = {
|
||||
'minetest': 'minetest_game', // world.mt has 'minetest' but directory is 'minetest_game'
|
||||
};
|
||||
|
||||
return reverseGameIdMap[internalGameId] || internalGameId;
|
||||
}
|
||||
|
||||
async getInstalledGames() {
|
||||
const games = [];
|
||||
// For Flatpak and other sandboxed installations, only look in the configured data directory
|
||||
|
@@ -308,6 +308,7 @@ class ServerManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
killProcessRecursive(pid, signal, callback) {
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
@@ -502,68 +503,39 @@ class ServerManager extends EventEmitter {
|
||||
|
||||
async createWorld(worldName, gameId = 'minetest_game') {
|
||||
try {
|
||||
const executableInfo = await this.findMinetestExecutable();
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const worldPath = paths.getWorldPath(worldName);
|
||||
|
||||
// Create world using Luanti --world command
|
||||
// This will initialize the world properly with the correct structure
|
||||
const createWorldArgs = [
|
||||
'--world', worldPath,
|
||||
'--gameid', gameId,
|
||||
'--server',
|
||||
'--go', // Skip main menu
|
||||
'--quiet' // Reduce output
|
||||
];
|
||||
|
||||
this.addLogLine('info', `Creating world "${worldName}" with game "${gameId}"`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const createProcess = spawn(executableInfo.command, [...executableInfo.args, ...createWorldArgs], {
|
||||
cwd: paths.minetestDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 30000 // 30 second timeout
|
||||
});
|
||||
// Map game ID for world creation (e.g., minetest_game -> minetest)
|
||||
const worldGameId = paths.mapGameIdForWorldCreation(gameId);
|
||||
if (worldGameId !== gameId) {
|
||||
this.addLogLine('info', `Mapping game ID: ${gameId} -> ${worldGameId} for world.mt`);
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
// Create world directory
|
||||
await fs.mkdir(worldPath, { recursive: true });
|
||||
|
||||
createProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
// Create only world.mt file - let Luanti create everything else
|
||||
const worldMtContent = `enable_damage = true
|
||||
creative_mode = false
|
||||
mod_storage_backend = sqlite3
|
||||
auth_backend = sqlite3
|
||||
player_backend = sqlite3
|
||||
backend = sqlite3
|
||||
gameid = ${worldGameId}
|
||||
world_name = ${worldName}
|
||||
server_announce = false
|
||||
`;
|
||||
|
||||
createProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
const worldMtPath = path.join(worldPath, 'world.mt');
|
||||
await fs.writeFile(worldMtPath, worldMtContent, 'utf8');
|
||||
|
||||
// Stop the server after a short time - we just want to initialize the world
|
||||
// Complex games like VoxeLibre/MineCLone2 need more time to initialize
|
||||
const timeout = gameId === 'mineclone2' || gameId.includes('clone') || gameId.includes('voxel') ? 8000 : 3000;
|
||||
this.addLogLine('info', `Using ${timeout}ms timeout for game: ${gameId}`);
|
||||
this.addLogLine('info', `World "${worldName}" created successfully`);
|
||||
|
||||
setTimeout(() => {
|
||||
if (createProcess && !createProcess.killed) {
|
||||
createProcess.kill('SIGTERM');
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
createProcess.on('close', (code) => {
|
||||
this.addLogLine('info', `World creation process finished with code: ${code}`);
|
||||
|
||||
// Check if world directory was created successfully
|
||||
if (fsSync.existsSync(worldPath)) {
|
||||
this.addLogLine('info', `World "${worldName}" created successfully`);
|
||||
resolve({ success: true, worldPath });
|
||||
} else {
|
||||
this.addLogLine('error', `World directory not created: ${worldPath}`);
|
||||
reject(new Error(`Failed to create world directory: ${worldPath}`));
|
||||
}
|
||||
});
|
||||
|
||||
createProcess.on('error', (error) => {
|
||||
this.addLogLine('error', `World creation failed: ${error.message}`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return { success: true, worldPath };
|
||||
|
||||
} catch (error) {
|
||||
this.addLogLine('error', `Failed to create world: ${error.message}`);
|
||||
|
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= title %> | LuHost</title>
|
||||
<% if (typeof csrfToken !== 'undefined' && csrfToken) { %>
|
||||
<meta name="csrf-token" content="<%= csrfToken %>">
|
||||
<% } %>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
</head>
|
||||
@@ -48,6 +51,11 @@
|
||||
Worlds
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/mods" class="nav-link <%= currentPage === 'mods' ? 'active' : '' %>">
|
||||
Mods
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/extensions" class="nav-link <%= currentPage === 'extensions' ? 'active' : '' %>">
|
||||
Extensions
|
||||
@@ -60,7 +68,7 @@
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<a href="/config" class="nav-link <%= currentPage === 'config' ? 'active' : '' %>">
|
||||
Configuration
|
||||
Config
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
|
Reference in New Issue
Block a user