diff --git a/gsm-backend/routes/servers.js b/gsm-backend/routes/servers.js index fe3ccc5..257f5e4 100644 --- a/gsm-backend/routes/servers.js +++ b/gsm-backend/routes/servers.js @@ -4,7 +4,7 @@ import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { EmbedBuilder } from 'discord.js'; import { authenticateToken, optionalAuth, requireRole, rejectGuest } from '../middleware/auth.js'; -import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig, readHytaleConfig, writeHytaleConfig } from '../services/ssh.js'; +import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig, readHytaleConfig, writeHytaleConfig, listSpaceEngineersWorlds, listSpaceEngineersTemplates, switchSpaceEngineersWorld, createSpaceEngineersWorldFromTemplate, cloneSpaceEngineersWorld, deleteSpaceEngineersWorld, renameSpaceEngineersWorld } from '../services/ssh.js'; import { supportsUpdate, checkForUpdate, performUpdate } from '../services/serverUpdates.js'; import { supportsVersionInfo, getVersionInfo, invalidateVersionCache } from '../services/serverVersions.js'; import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js'; @@ -303,6 +303,111 @@ router.get("/factorio/current-save", authenticateToken, async (req, res) => { } }); +// ============ SPACE ENGINEERS WORLD ROUTES ============ + +function getSEServer() { + return loadConfig().servers.find(s => s.type === "spaceengineers"); +} + +// SE: List worlds (active + library) +router.get("/spaceengineers/worlds", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const server = getSEServer(); + if (!server) return res.status(404).json({ error: "Space Engineers server not configured" }); + const data = await listSpaceEngineersWorlds(server); + res.json(data); + } catch (err) { + res.status(500).json({ error: err.message, message: err.message }); + } +}); + +// SE: List built-in templates +router.get("/spaceengineers/templates", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const server = getSEServer(); + if (!server) return res.status(404).json({ error: "Space Engineers server not configured" }); + const templates = await listSpaceEngineersTemplates(server); + res.json({ templates }); + } catch (err) { + res.status(500).json({ error: err.message, message: err.message }); + } +}); + +// SE: Create new world from a template +router.post("/spaceengineers/worlds", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const { name, template } = req.body || {}; + if (!name || !template) return res.status(400).json({ error: "name and template required", message: "name und template erforderlich" }); + const server = getSEServer(); + if (!server) return res.status(404).json({ error: "Space Engineers server not configured" }); + const result = await createSpaceEngineersWorldFromTemplate(server, name, template); + logActivity(req.user.id, req.user.username, 'se_world_create', server.id, `${result.name} (Vorlage: ${template})`, req.user.discordId, req.user.avatar); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message, message: err.message }); + } +}); + +// SE: Clone the active world +router.post("/spaceengineers/worlds/clone", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const { name } = req.body || {}; + if (!name) return res.status(400).json({ error: "name required", message: "name erforderlich" }); + const server = getSEServer(); + if (!server) return res.status(404).json({ error: "Space Engineers server not configured" }); + const status = await getServerStatus(server); + if (status === "online") return res.status(409).json({ error: "Server muss gestoppt sein zum Klonen", message: "Server muss gestoppt sein zum Klonen" }); + const result = await cloneSpaceEngineersWorld(server, name); + logActivity(req.user.id, req.user.username, 'se_world_clone', server.id, result.name, req.user.discordId, req.user.avatar); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message, message: err.message }); + } +}); + +// SE: Switch the active world (server must be stopped) +router.post("/spaceengineers/worlds/:name/activate", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const server = getSEServer(); + if (!server) return res.status(404).json({ error: "Space Engineers server not configured" }); + const status = await getServerStatus(server); + if (status === "online") return res.status(409).json({ error: "Server muss gestoppt sein zum Wechseln der Welt", message: "Server muss gestoppt sein zum Wechseln der Welt" }); + const result = await switchSpaceEngineersWorld(server, req.params.name); + logActivity(req.user.id, req.user.username, 'se_world_activate', server.id, result.active, req.user.discordId, req.user.avatar); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message, message: err.message }); + } +}); + +// SE: Rename a world +router.patch("/spaceengineers/worlds/:name", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const { newName } = req.body || {}; + if (!newName) return res.status(400).json({ error: "newName required", message: "newName erforderlich" }); + const server = getSEServer(); + if (!server) return res.status(404).json({ error: "Space Engineers server not configured" }); + const result = await renameSpaceEngineersWorld(server, req.params.name, newName); + logActivity(req.user.id, req.user.username, 'se_world_rename', server.id, `${req.params.name} -> ${result.name}`, req.user.discordId, req.user.avatar); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message, message: err.message }); + } +}); + +// SE: Delete a world (not the active one) +router.delete("/spaceengineers/worlds/:name", authenticateToken, requireRole("moderator"), async (req, res) => { + try { + const server = getSEServer(); + if (!server) return res.status(404).json({ error: "Space Engineers server not configured" }); + const result = await deleteSpaceEngineersWorld(server, req.params.name); + logActivity(req.user.id, req.user.username, 'se_world_delete', server.id, result.name, req.user.discordId, req.user.avatar); + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message, message: err.message }); + } +}); + // ============ ZOMBOID CONFIG ROUTES ============ // Zomboid: List config files diff --git a/gsm-backend/services/ssh.js b/gsm-backend/services/ssh.js index 12d744a..373a8ab 100644 --- a/gsm-backend/services/ssh.js +++ b/gsm-backend/services/ssh.js @@ -782,6 +782,257 @@ export async function getSpaceEngineersPlayers(server) { } } +// ============ SPACE ENGINEERS WORLD MANAGEMENT ============ +// +// Unlike Factorio (which keeps many named saves in one folder and picks one via +// an env var on `docker run`), the SE image always loads exactly ONE flat world +// directory: host `/appdata/space-engineers/config/World` -> container +// `/appdata/space-engineers/World`. The image entrypoint even rewrites +// back to that fixed path on every start, so repointing the cfg is +// pointless and the world MUST be flat (Sandbox.sbc directly inside) or the +// container exits (code 130). +// +// We therefore keep a library of inactive worlds in a sibling folder `Worlds` +// (plural, NOT mounted into the container) and "switch" by moving directories. +// `Worlds` lives on the same filesystem as `World`, so the move is an instant +// rename even for multi-GB worlds. The active world's display name is tracked in +// a marker file `.gsm-worldname` inside `World`. All files are owned by UID/GID +// 1000 (the in-container wine user), so we chown after every mutation. + +function sePaths(server) { + const base = `${server.workDir}/appdata/space-engineers`; + return { + base, + config: `${base}/config`, + active: `${base}/config/World`, + library: `${base}/config/Worlds`, + templates: `${base}/bins/SpaceEngineersDedicated/Content/CustomWorlds`, + }; +} + +// World/template names map to directory names. Allow letters, numbers, spaces, +// underscore and hyphen only — this both keeps the file system tidy and makes it +// safe to interpolate the (double-quoted) name into shell commands. +function validateSEWorldName(name) { + if (!name || typeof name !== 'string') throw new Error('Weltname fehlt'); + const trimmed = name.trim(); + if (trimmed.includes('..')) throw new Error('Ungültiger Weltname'); + if (!/^[A-Za-z0-9 _-]{1,48}$/.test(trimmed)) { + throw new Error('Ungültiger Weltname (erlaubt: Buchstaben, Zahlen, Leerzeichen, _ und -, max. 48 Zeichen)'); + } + return trimmed; +} + +function sanitizeSEName(raw) { + const s = (raw || '').trim().replace(/[^A-Za-z0-9 _-]/g, '').slice(0, 48).trim(); + return s || 'World'; +} + +// Resolve the active world's display name. Prefer the marker file; otherwise +// derive it from the cfg (falling back to ) and persist +// a marker so subsequent switches have a stable name to archive under. +async function ensureSEActiveMarker(server, ssh) { + const p = sePaths(server); + const marker = await ssh.execCommand(`cat "${p.active}/.gsm-worldname" 2>/dev/null`); + if (marker.stdout.trim()) return marker.stdout.trim(); + + let derived = ''; + const cfg = await ssh.execCommand( + `grep -m1 -oE "[^<]*" "${p.config}/SpaceEngineers-Dedicated.cfg" 2>/dev/null | sed -E "s///"` + ); + derived = cfg.stdout.trim(); + if (!derived) { + const sess = await ssh.execCommand( + `grep -m1 -oE "[^<]*" "${p.active}/Sandbox_config.sbc" 2>/dev/null | sed -E "s///"` + ); + derived = sess.stdout.trim(); + } + derived = sanitizeSEName(derived); + // best effort: persist marker so the active name is stable from now on + await ssh.execCommand( + `echo "${derived}" > "${p.active}/.gsm-worldname" && chown 1000:1000 "${p.active}/.gsm-worldname" 2>/dev/null || true` + ); + return derived; +} + +// List the active world + every world in the library, with size and mtime. +export async function listSpaceEngineersWorlds(server) { + const ssh = await getConnection(server.host, server.sshUser); + const p = sePaths(server); + await ssh.execCommand(`mkdir -p "${p.library}"`); + await ensureSEActiveMarker(server, ssh); + + // One pass emits "active|name|hasSandbox|size|mtime" per world. Names are + // restricted to a charset without "|" so the delimiter is unambiguous. + const script = + `ACTIVE="${p.active}"; LIB="${p.library}"; ` + + `emit() { d="$1"; act="$2"; nm="$3"; has=0; [ -f "$d/Sandbox.sbc" ] && has=1; ` + + `size=$(du -sh "$d" 2>/dev/null | cut -f1); ` + + `mt=$(stat -c %Y "$d/Sandbox.sbc" 2>/dev/null || echo 0); ` + + `echo "$act|$nm|$has|$size|$mt"; }; ` + + `an=$(cat "$ACTIVE/.gsm-worldname" 2>/dev/null); [ -z "$an" ] && an="World"; ` + + `emit "$ACTIVE" 1 "$an"; ` + + `for d in "$LIB"/*/ ; do [ -d "$d" ] || continue; emit "${'${d%/}'}" 0 "$(basename "$d")"; done`; + const r = await ssh.execCommand(script); + + const worlds = []; + for (const line of r.stdout.split('\n').map(l => l.trim()).filter(Boolean)) { + const parts = line.split('|'); + if (parts.length < 5) continue; + const [act, name, has, size, mt] = parts; + worlds.push({ + name, + active: act === '1', + valid: has === '1', + size: size || '-', + modified: mt && mt !== '0' ? Number(mt) * 1000 : null, + }); + } + const active = worlds.find(w => w.active)?.name || null; + return { active, worlds }; +} + +// Built-in premade worlds shipped with the dedicated server, usable as templates +// for a fresh world. Some templates store the world under a PC/ subfolder. +export async function listSpaceEngineersTemplates(server) { + const ssh = await getConnection(server.host, server.sshUser); + const p = sePaths(server); + const script = + `for d in "${p.templates}"/*/ ; do [ -d "$d" ] || continue; nm="$(basename "$d")"; ` + + `if [ -f "$d/Sandbox.sbc" ] || [ -f "$d/PC/Sandbox.sbc" ]; then echo "$nm"; fi; done`; + const r = await ssh.execCommand(script); + return r.stdout.split('\n').map(s => s.trim()).filter(Boolean); +} + +// Swap the active world. The server MUST be stopped (checked by the route). +export async function switchSpaceEngineersWorld(server, targetName) { + const name = validateSEWorldName(targetName); + const ssh = await getConnection(server.host, server.sshUser); + const p = sePaths(server); + + const current = await ensureSEActiveMarker(server, ssh); + if (name === current) return { message: 'Welt ist bereits aktiv', active: name }; + + const tgt = await ssh.execCommand(`test -f "${p.library}/${name}/Sandbox.sbc" && echo OK`); + if (!tgt.stdout.includes('OK')) { + throw new Error(`Welt "${name}" nicht in der Bibliothek gefunden (oder keine Sandbox.sbc)`); + } + const coll = await ssh.execCommand(`test -e "${p.library}/${current}" && echo EXISTS`); + if (coll.stdout.includes('EXISTS')) { + throw new Error(`Kann aktive Welt nicht archivieren: "Worlds/${current}" existiert bereits`); + } + + const cmd = + `set -e; mkdir -p "${p.library}"; ` + + `mv "${p.active}" "${p.library}/${current}"; ` + + `mv "${p.library}/${name}" "${p.active}"; ` + + `echo "${name}" > "${p.active}/.gsm-worldname"; ` + + `chown -R 1000:1000 "${p.active}" "${p.library}/${current}";`; + const r = await ssh.execCommand(cmd); + if (r.code !== 0) throw new Error(r.stderr || 'Weltwechsel fehlgeschlagen'); + + const ver = await ssh.execCommand(`test -f "${p.active}/Sandbox.sbc" && echo OK`); + if (!ver.stdout.includes('OK')) throw new Error('Weltwechsel-Verifikation fehlgeschlagen (Sandbox.sbc fehlt)'); + return { message: 'Welt gewechselt', active: name }; +} + +// Create a new library world from a built-in template. +export async function createSpaceEngineersWorldFromTemplate(server, newName, templateName) { + const name = validateSEWorldName(newName); + const tmpl = validateSEWorldName(templateName); + const ssh = await getConnection(server.host, server.sshUser); + const p = sePaths(server); + + const active = await ensureSEActiveMarker(server, ssh); + if (name === active) throw new Error('Eine Welt mit diesem Namen ist gerade aktiv'); + const exists = await ssh.execCommand(`test -e "${p.library}/${name}" && echo EXISTS`); + if (exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" existiert bereits`); + + const src = await ssh.execCommand( + `if [ -f "${p.templates}/${tmpl}/Sandbox.sbc" ]; then echo "${p.templates}/${tmpl}"; ` + + `elif [ -f "${p.templates}/${tmpl}/PC/Sandbox.sbc" ]; then echo "${p.templates}/${tmpl}/PC"; fi` + ); + const srcDir = src.stdout.trim(); + if (!srcDir) throw new Error(`Vorlage "${tmpl}" nicht gefunden oder ohne ladbare Welt`); + + const cmd = + `set -e; mkdir -p "${p.library}/${name}"; ` + + `cp -a "${srcDir}/." "${p.library}/${name}/"; ` + + `echo "${name}" > "${p.library}/${name}/.gsm-worldname"; ` + + `chown -R 1000:1000 "${p.library}/${name}";`; + const r = await ssh.execCommand(cmd, { execOptions: { timeout: 120000 } }); + if (r.code !== 0) throw new Error(r.stderr || 'Welterstellung fehlgeschlagen'); + return { message: 'Welt erstellt', name }; +} + +// Duplicate the currently active world into the library. Server MUST be stopped +// (checked by the route) so the copy is not torn by an autosave. +export async function cloneSpaceEngineersWorld(server, newName) { + const name = validateSEWorldName(newName); + const ssh = await getConnection(server.host, server.sshUser); + const p = sePaths(server); + + const active = await ensureSEActiveMarker(server, ssh); + if (name === active) throw new Error('Klon-Name muss sich von der aktiven Welt unterscheiden'); + const exists = await ssh.execCommand(`test -e "${p.library}/${name}" && echo EXISTS`); + if (exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" existiert bereits`); + + const cmd = + `set -e; mkdir -p "${p.library}/${name}"; ` + + `cp -a "${p.active}/." "${p.library}/${name}/"; ` + + `echo "${name}" > "${p.library}/${name}/.gsm-worldname"; ` + + `chown -R 1000:1000 "${p.library}/${name}";`; + const r = await ssh.execCommand(cmd, { execOptions: { timeout: 300000 } }); + if (r.code !== 0) throw new Error(r.stderr || 'Klonen fehlgeschlagen'); + return { message: 'Welt geklont', name }; +} + +export async function deleteSpaceEngineersWorld(server, worldName) { + const name = validateSEWorldName(worldName); + const ssh = await getConnection(server.host, server.sshUser); + const p = sePaths(server); + + const active = await ensureSEActiveMarker(server, ssh); + if (name === active) throw new Error('Aktive Welt kann nicht gelöscht werden. Wechsle zuerst zu einer anderen Welt.'); + const exists = await ssh.execCommand(`test -d "${p.library}/${name}" && echo EXISTS`); + if (!exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" nicht gefunden`); + + const r = await ssh.execCommand(`rm -rf "${p.library}/${name}"`); + if (r.code !== 0) throw new Error(r.stderr || 'Löschen fehlgeschlagen'); + return { message: 'Welt gelöscht', name }; +} + +export async function renameSpaceEngineersWorld(server, oldName, newName) { + const oldN = validateSEWorldName(oldName); + const newN = validateSEWorldName(newName); + if (oldN === newN) return { message: 'Keine Änderung', name: newN }; + const ssh = await getConnection(server.host, server.sshUser); + const p = sePaths(server); + + const active = await ensureSEActiveMarker(server, ssh); + if (newN === active) throw new Error('Eine Welt mit dem neuen Namen ist gerade aktiv'); + const collide = await ssh.execCommand(`test -e "${p.library}/${newN}" && echo EXISTS`); + if (collide.stdout.includes('EXISTS')) throw new Error(`Welt "${newN}" existiert bereits`); + + if (oldN === active) { + const r = await ssh.execCommand( + `echo "${newN}" > "${p.active}/.gsm-worldname" && chown 1000:1000 "${p.active}/.gsm-worldname"` + ); + if (r.code !== 0) throw new Error(r.stderr || 'Umbenennen fehlgeschlagen'); + return { message: 'Aktive Welt umbenannt', name: newN }; + } + + const exists = await ssh.execCommand(`test -d "${p.library}/${oldN}" && echo EXISTS`); + if (!exists.stdout.includes('EXISTS')) throw new Error(`Welt "${oldN}" nicht gefunden`); + const r = await ssh.execCommand( + `set -e; mv "${p.library}/${oldN}" "${p.library}/${newN}"; ` + + `echo "${newN}" > "${p.library}/${newN}/.gsm-worldname"; ` + + `chown 1000:1000 "${p.library}/${newN}/.gsm-worldname";` + ); + if (r.code !== 0) throw new Error(r.stderr || 'Umbenennen fehlgeschlagen'); + return { message: 'Welt umbenannt', name: newN }; +} + // ============ HYTALE FUNCTIONS ============ const HYTALE_CONFIG_PATH = "/opt/hytale/Server/config.json"; const HYTALE_LOGS_PATH = "/opt/hytale/Server/logs"; diff --git a/gsm-frontend/src/api.js b/gsm-frontend/src/api.js index 3bc93b6..390eea4 100644 --- a/gsm-frontend/src/api.js +++ b/gsm-frontend/src/api.js @@ -284,6 +284,57 @@ export async function getFactorioWorldSettings(token, saveName) { }) } +// Space Engineers World Management +export async function getSEWorlds(token) { + return fetchAPI('/servers/spaceengineers/worlds', { + headers: { Authorization: `Bearer ${token}` }, + }) +} + +export async function getSETemplates(token) { + return fetchAPI('/servers/spaceengineers/templates', { + headers: { Authorization: `Bearer ${token}` }, + }) +} + +export async function createSEWorld(token, name, template) { + return fetchAPI('/servers/spaceengineers/worlds', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name, template }), + }) +} + +export async function cloneSEWorld(token, name) { + return fetchAPI('/servers/spaceengineers/worlds/clone', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ name }), + }) +} + +export async function activateSEWorld(token, name) { + return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}/activate`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) +} + +export async function renameSEWorld(token, name, newName) { + return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ newName }), + }) +} + +export async function deleteSEWorld(token, name) { + return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) +} + // Auto-Shutdown Settings export async function getAutoShutdownSettings(token, serverId) { return fetchAPI(`/servers/${serverId}/autoshutdown`, { diff --git a/gsm-frontend/src/components/SpaceEngineersWorldManager.jsx b/gsm-frontend/src/components/SpaceEngineersWorldManager.jsx new file mode 100644 index 0000000..2939677 --- /dev/null +++ b/gsm-frontend/src/components/SpaceEngineersWorldManager.jsx @@ -0,0 +1,403 @@ +import { useState, useEffect } from 'react' +import { + getSEWorlds, + getSETemplates, + createSEWorld, + cloneSEWorld, + activateSEWorld, + renameSEWorld, + deleteSEWorld, +} from '../api' + +const NAME_RE = /^[a-zA-Z0-9 _-]{1,48}$/ + +export default function SpaceEngineersWorldManager({ server, token, onServerAction }) { + const [worlds, setWorlds] = useState([]) + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [actionLoading, setActionLoading] = useState(null) + + // modals + const [showCreate, setShowCreate] = useState(false) + const [newName, setNewName] = useState('') + const [newTemplate, setNewTemplate] = useState('') + const [busy, setBusy] = useState(false) + + const [showClone, setShowClone] = useState(false) + const [cloneName, setCloneName] = useState('') + + const [renaming, setRenaming] = useState(null) // world being renamed + const [renameValue, setRenameValue] = useState('') + + const [deleteConfirm, setDeleteConfirm] = useState(null) + + // The server must be stopped to swap or clone the active world. + const running = server.running || server.status === 'starting' || server.status === 'stopping' + + const fetchData = async () => { + try { + setLoading(true) + const [worldsData, templatesData] = await Promise.all([ + getSEWorlds(token), + getSETemplates(token), + ]) + // active world first, rest alphabetically + const list = (worldsData.worlds || []).slice().sort((a, b) => { + if (a.active !== b.active) return a.active ? -1 : 1 + return a.name.localeCompare(b.name) + }) + setWorlds(list) + setTemplates(templatesData.templates || []) + if (!newTemplate && templatesData.templates?.length) setNewTemplate(templatesData.templates[0]) + setError('') + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchData() + }, [token]) + + const fmtDate = (ms) => (ms ? new Date(ms).toLocaleString() : '—') + + const handleActivate = async (name) => { + try { + setActionLoading(name) + setError('') + await activateSEWorld(token, name) + await fetchData() + if (onServerAction) onServerAction() + } catch (err) { + setError(err.message) + } finally { + setActionLoading(null) + } + } + + const handleCreate = async () => { + if (!NAME_RE.test(newName.trim())) { + setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)') + return + } + if (!newTemplate) { + setError('Bitte eine Vorlage auswählen') + return + } + try { + setBusy(true) + setError('') + await createSEWorld(token, newName.trim(), newTemplate) + setShowCreate(false) + setNewName('') + await fetchData() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + const handleClone = async () => { + if (!NAME_RE.test(cloneName.trim())) { + setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)') + return + } + try { + setBusy(true) + setError('') + await cloneSEWorld(token, cloneName.trim()) + setShowClone(false) + setCloneName('') + await fetchData() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + const handleRename = async () => { + if (!NAME_RE.test(renameValue.trim())) { + setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)') + return + } + try { + setBusy(true) + setError('') + await renameSEWorld(token, renaming.name, renameValue.trim()) + setRenaming(null) + setRenameValue('') + await fetchData() + } catch (err) { + setError(err.message) + } finally { + setBusy(false) + } + } + + const handleDelete = async (name) => { + try { + setActionLoading(name) + setError('') + await deleteSEWorld(token, name) + setDeleteConfirm(null) + await fetchData() + } catch (err) { + setError(err.message) + } finally { + setActionLoading(null) + } + } + + if (loading) { + return ( +
+
Lade Welten...
+
+ ) + } + + return ( +
+ {error &&
{error}
} + + {running && ( +
+
+ Der Server läuft. Welt wechseln und klonen ist nur bei gestopptem Server möglich. +
+
+ )} + + {/* Create World Modal */} + {showCreate && ( +
!busy && setShowCreate(false)}> +
e.stopPropagation()}> +
+

Neue Welt erstellen

+
+
+
+ + setNewName(e.target.value)} + placeholder="meine-neue-welt" + disabled={busy} + className="input" + /> +
+
+ + +

+ Die Welt wird aus einer der mitgelieferten Server-Vorlagen erzeugt und in die + Bibliothek gelegt. Aktivieren musst du sie danach separat. +

+
+
+
+ + +
+
+
+ )} + + {/* Clone Modal */} + {showClone && ( +
!busy && setShowClone(false)}> +
e.stopPropagation()}> +
+

Aktuelle Welt klonen

+
+
+
+ + setCloneName(e.target.value)} + placeholder="welt-backup" + disabled={busy} + className="input" + /> +
+

+ Kopiert die aktuell aktive Welt inklusive aller Bauten, Charaktere und Inventare in + die Bibliothek. Das kann je nach Weltgröße einen Moment dauern. +

+
+
+ + +
+
+
+ )} + + {/* Rename Modal */} + {renaming && ( +
!busy && setRenaming(null)}> +
e.stopPropagation()}> +
+

Welt umbenennen

+
+
+
+ + setRenameValue(e.target.value)} + disabled={busy} + className="input" + /> +
+
+
+ + +
+
+
+ )} + + {/* Delete Confirmation */} + {deleteConfirm && ( +
setDeleteConfirm(null)}> +
e.stopPropagation()}> +
+

Welt löschen?

+
+
+

+ Welt {deleteConfirm} wirklich löschen? + Das kann nicht rückgängig gemacht werden. +

+
+
+ + +
+
+
+ )} + + {/* Header */} +
+

Welten

+
+ + +
+
+ + {/* World List */} + {worlds.length === 0 ? ( +
+
Keine Welten gefunden
+
+ ) : ( +
+ {worlds.map((world) => ( +
+
+
+ {world.active ? '🪐' : '🌑'} +
+
+ {world.name} + {world.active && Aktiv} + {!world.valid && ⚠️ keine Sandbox.sbc} +
+
+ {world.size} · {fmtDate(world.modified)} +
+
+
+
+ {!world.active && ( + + )} + + {!world.active && ( + + )} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/gsm-frontend/src/pages/ServerDetail.jsx b/gsm-frontend/src/pages/ServerDetail.jsx index f8f5a96..b3fe087 100644 --- a/gsm-frontend/src/pages/ServerDetail.jsx +++ b/gsm-frontend/src/pages/ServerDetail.jsx @@ -5,6 +5,7 @@ import { useUser } from '../context/UserContext' import MetricsChart from '../components/MetricsChart' import ConfirmModal from '../components/ConfirmModal' import FactorioWorldManager from '../components/FactorioWorldManager' +import SpaceEngineersWorldManager from '../components/SpaceEngineersWorldManager' import PalworldConfigEditor from '../components/PalworldConfigEditor' import ZomboidConfigEditor from '../components/ZomboidConfigEditor' import TerrariaConfigEditor from '../components/TerrariaConfigEditor' @@ -318,7 +319,7 @@ const formatUptime = (seconds) => { ...(isModerator && server?.type === 'minecraft' ? [ { id: 'whitelist', label: 'Whitelist' }, ] : []), - ...(isModerator && server?.type === 'factorio' ? [ + ...(isModerator && (server?.type === 'factorio' || server?.type === 'spaceengineers') ? [ { id: 'worlds', label: 'Welten' }, ] : []), ...(isModerator && server?.type === 'palworld' ? [ @@ -691,6 +692,18 @@ const formatUptime = (seconds) => { )} + {/* Worlds Tab - Space Engineers only. The manager stays usable while the + server runs (switch/clone are disabled), so no full lock here. */} + {activeTab === 'worlds' && isModerator && server.type === 'spaceengineers' && ( +
+ +
+ )} + {/* Config Tab - Palworld only */} {activeTab === 'config' && isModerator && server.type === 'palworld' && (