diff --git a/AdminPanel.cs b/AdminPanel.cs
index 0489cf5..79ca81f 100644
--- a/AdminPanel.cs
+++ b/AdminPanel.cs
@@ -251,7 +251,7 @@ public sealed class AdminPanel
void MapFiles()
{
- byte[] html = Resource("panel.index.html"), js = Resource("panel.app.js"), css = Resource("panel.app.css"), logo = Resource("panel.logo.png");
+ byte[] html = Resource("panel.index.html"), js = Resource("panel.app.js"), i18n = Resource("panel.i18n.js"), css = Resource("panel.app.css"), logo = Resource("panel.logo.png");
app.MapGet("/", (HttpContext ctx) =>
{
// /panel ohne Schrägstrich -> /panel/ (sonst zeigen die relativen Adressen der Seite ins Leere)
@@ -260,6 +260,7 @@ public sealed class AdminPanel
return Results.Bytes(html, "text/html; charset=utf-8");
});
app.MapGet("/app.js", () => Results.Bytes(js, "text/javascript; charset=utf-8"));
+ app.MapGet("/i18n.js", () => Results.Bytes(i18n, "text/javascript; charset=utf-8"));
app.MapGet("/app.css", () => Results.Bytes(css, "text/css; charset=utf-8"));
if (logo != null) app.MapGet("/logo.png", () => Results.Bytes(logo, "image/png"));
}
@@ -410,6 +411,35 @@ public sealed class AdminPanel
return await RunText(s => s.PanelUnban(id, by), () => OfflineBan(id, false, by));
});
+ // --- Ingame-Admins (dürfen im Spiel /admin benutzen)
+ app.MapGet("/api/admins", async (HttpContext ctx) =>
+ {
+ var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
+ return await Run(s => s.PanelAdmins(), () => new
+ {
+ steamVerification = !string.IsNullOrWhiteSpace(config.SteamWebApiKey),
+ admins = config.Admins.Select(id => new { steamId = id, name = (string)null, online = false }).ToList()
+ });
+ });
+
+ app.MapPost("/api/admins/{action}", async (HttpContext ctx, string action) =>
+ {
+ var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
+ var body = await Body(ctx);
+ string id = Str(body, "id").Trim(), by = By(user);
+ if (id.Length != 17 || !id.All(char.IsDigit)) return Error(400, "Enter a SteamID64 (17 digits)");
+ if (action != "add" && action != "remove") return Error(404, "Unknown action");
+ bool add = action == "add";
+ return await RunText(s => s.RunCommandCaptured((add ? "op " : "deop ") + id, by), () =>
+ {
+ config.Admins.Remove(id);
+ if (add) config.Admins.Add(id);
+ try { config.Save(host.ConfigPath); } catch (Exception e) { return "Could not save server.json: " + e.Message; }
+ GameServer.Log($"SteamID {id} is {(add ? "now an admin" : "no longer an admin")} ({by})");
+ return $"SteamID {id} is {(add ? "now an admin" : "no longer an admin")}";
+ });
+ });
+
// --- Konsole
app.MapGet("/api/console", (HttpContext ctx) =>
{
@@ -512,9 +542,18 @@ public sealed class AdminPanel
zip = ms.ToArray();
}
catch (Exception e) { return Error(400, "Upload failed: " + e.Message); }
- if (zip.Length == 0) return Error(400, "Choose a ZIP file");
+ if (zip.Length == 0) return Error(400, "Choose a ZIP, RAR or 7z file");
string by = By(user);
- return await RunText(s => s.ImportSaveZip(zip, by), () => SaveStore.ImportZipOffline(dataDir, zip, by));
+ var server = host.Server;
+ string result;
+ if (server == null || host.State != HostState.Running) result = SaveStore.ImportZipOffline(dataDir, zip, by);
+ else
+ {
+ var task = server.Invoke(() => server.ImportSaveZip(zip, by));
+ if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy");
+ result = await task;
+ }
+ return result.StartsWith("Save game uploaded") ? Ok(result) : Error(400, result);
});
// --- Benutzer (nur Owner)
diff --git a/GameServer.cs b/GameServer.cs
index 4051385..21ffcd1 100644
--- a/GameServer.cs
+++ b/GameServer.cs
@@ -1097,6 +1097,17 @@ public sealed class GameServer
public object PanelBans() => config.BannedSteamIds.ToList();
+ /// Ingame-Admins (SteamID64) mit Namen, falls gerade online.
+ public object PanelAdmins() => new
+ {
+ steamVerification = steam.Enabled,
+ admins = config.Admins.Select(id =>
+ {
+ var online = clients.Values.FirstOrDefault(c => c.State == ClientState.Joined && c.SteamId == id);
+ return new { steamId = id, name = online?.Name, online = online != null };
+ }).ToList()
+ };
+
// ---------------------------------------------------------------- Stopp / Neustart (der Host startet neu oder wartet)
/// Nach dem Ende von Run() den Server neu aufbauen (Einstellungen, Lizenz, Spielstand neu laden).
@@ -1149,7 +1160,7 @@ public sealed class GameServer
///
public string ImportSaveZip(byte[] zip, string by)
{
- string error = SaveStore.ExtractZip(zip, out var found);
+ string error = SaveStore.ExtractArchive(zip, out var found);
if (error != null) return error;
try
{
diff --git a/MSCO.Server.csproj b/MSCO.Server.csproj
index 797a70e..4d750eb 100644
--- a/MSCO.Server.csproj
+++ b/MSCO.Server.csproj
@@ -20,6 +20,7 @@
+
@@ -30,4 +31,8 @@
+
+
+
+
diff --git a/Panel/app.css b/Panel/app.css
index a4da64b..e19dd4a 100644
--- a/Panel/app.css
+++ b/Panel/app.css
@@ -82,19 +82,51 @@ label input, label select { margin-top: 6px; text-transform: none; letter-spacin
.brand-sub { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
/* ---------------------------------------------------------------- Layout */
-.layout { display: grid; grid-template-columns: 232px 1fr; min-height: 100%; }
-.sidebar { background: var(--sidebar); padding: 20px 14px; display: flex; flex-direction: column; gap: 22px;
- position: sticky; top: 0; height: 100vh; }
-.sidebar .brand { padding: 0 8px; }
+.layout { display: grid; grid-template-columns: 248px 1fr; min-height: 100%; }
+.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 18px; padding: 20px 14px 16px;
+ background: linear-gradient(180deg, #121216 0%, #0d0d10 100%); border-right: 1px solid var(--border); overflow-y: auto; }
+.sidebar .brand { padding: 2px 8px 0; }
+
+.server-card { display: flex; align-items: center; gap: 11px; padding: 11px 12px; border-radius: 12px;
+ background: rgba(255, 255, 255, .03); border: 1px solid var(--border); }
+.sc-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--pending); box-shadow: 0 0 0 4px rgba(242, 194, 48, .12); }
+.sc-dot.ok { background: var(--online); box-shadow: 0 0 0 4px rgba(61, 214, 140, .14); }
+.sc-dot.bad { background: var(--danger); box-shadow: 0 0 0 4px rgba(229, 72, 77, .14); }
+.sc-text { min-width: 0; display: grid; }
+.sc-text b { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.sc-text small { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
nav { display: grid; gap: 2px; }
-nav button { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: transparent;
- border: 0; color: var(--muted); font: inherit; font-weight: 600; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
-nav button:hover { background: #18181d; color: var(--text); }
-nav button.active { background: var(--panel-2); color: var(--text); box-shadow: inset 3px 0 0 var(--accent); }
-nav .ico { width: 18px; text-align: center; opacity: .9; }
-.sidebar-foot { margin-top: auto; display: grid; gap: 8px; padding: 0 8px; }
-.me { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; overflow: hidden; }
-.me span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.nav-group { font-size: 10.5px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: #5f5f6b; padding: 14px 12px 6px; }
+.nav-group:first-child { padding-top: 2px; }
+nav button { display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; background: transparent; border: 0;
+ color: #a4a4af; font: inherit; font-weight: 600; padding: 7px 10px; border-radius: 10px; cursor: pointer; transition: background .12s, color .12s; }
+nav button:hover { background: rgba(255, 255, 255, .04); color: var(--text); }
+nav .ico { width: 28px; height: 28px; flex: none; display: grid; place-items: center; border-radius: 8px; font-size: 13px;
+ background: rgba(255, 255, 255, .04); color: #c9c9d2; transition: background .12s, color .12s; }
+nav button.active { background: linear-gradient(90deg, rgba(242, 107, 29, .16), rgba(242, 107, 29, .04)); color: #fff; }
+nav button.active .ico { background: var(--accent); color: #fff; box-shadow: 0 4px 14px rgba(242, 107, 29, .35); }
+.nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.sidebar-foot { margin-top: auto; display: grid; gap: 10px; }
+.me-card { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 12px; background: rgba(255, 255, 255, .03); border: 1px solid var(--border); }
+.me-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; background: transparent; border: 0; color: var(--text);
+ font: inherit; text-align: left; padding: 4px; border-radius: 8px; cursor: pointer; }
+.me-main:hover, .me-main.active { background: rgba(255, 255, 255, .05); }
+.avatar { width: 32px; height: 32px; flex: none; border-radius: 50%; display: grid; place-items: center; font-weight: 800;
+ background: linear-gradient(135deg, #ffa24d, #e5561a); color: #fff; }
+.me-text { min-width: 0; display: grid; }
+.me-text b { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.me-text small { color: var(--muted); font-size: 11.5px; }
+.icon-btn { width: 32px; height: 32px; flex: none; border: 0; border-radius: 8px; background: transparent; color: var(--muted);
+ font-size: 15px; cursor: pointer; }
+.icon-btn:hover { background: rgba(229, 72, 77, .14); color: #ff8a8d; }
+
+.lang-switch { display: inline-flex; padding: 3px; border-radius: 999px; background: rgba(255, 255, 255, .04); border: 1px solid var(--border); justify-self: start; }
+.lang-switch button { border: 0; background: transparent; color: var(--muted); font: inherit; font-size: 12px; font-weight: 700;
+ padding: 4px 12px; border-radius: 999px; cursor: pointer; }
+.lang-switch button.on { background: var(--panel-2); color: var(--text); box-shadow: 0 1px 0 rgba(255, 255, 255, .04); }
+.auth-lang { position: fixed; top: 16px; right: 16px; }
.content { min-width: 0; display: flex; flex-direction: column; }
.topbar { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-bottom: 1px solid var(--border);
@@ -108,8 +140,14 @@ nav .ico { width: 18px; text-align: center; opacity: .9; }
.page { padding: 24px 28px 40px; display: grid; gap: 20px; align-content: start; }
/* ---------------------------------------------------------------- Karten */
-.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
-.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }
+.cards.wide { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
+.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; min-width: 0;
+ display: flex; flex-direction: column; gap: 10px; overflow-wrap: anywhere; }
+.card p { margin: 0; }
+.card > .btn, .card > .row { margin-top: auto; }
+.card .row { gap: 8px; }
+.card .row > input, .card .row > input[type=file] { flex: 1 1 100%; min-width: 0; width: 100%; }
.stat-label { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.stat-value { font-size: 26px; font-weight: 800; margin-top: 6px; font-variant-numeric: tabular-nums; }
.stat-sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
@@ -118,7 +156,9 @@ nav .ico { width: 18px; text-align: center; opacity: .9; }
.section-head h2 { margin-right: auto; }
.section-body { padding: 18px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
-.row > input { flex: 1; min-width: 180px; }
+.row > input { flex: 1; min-width: min(180px, 100%); }
+.small-text { font-size: 13px; padding-top: 0; }
+.page { min-width: 0; }
.empty { color: var(--muted); padding: 26px 18px; text-align: center; }
/* ---------------------------------------------------------------- Tabellen */
@@ -183,7 +223,7 @@ input[type=file]::file-selector-button { background: var(--panel); color: var(--
/* ---------------------------------------------------------------- Handy */
@media (max-width: 820px) {
.layout { grid-template-columns: 1fr; }
- .sidebar { position: fixed; inset: 0 auto 0 0; width: 250px; z-index: 40; transform: translateX(-100%); transition: transform .18s; }
+ .sidebar { position: fixed; inset: 0 auto 0 0; width: 268px; z-index: 40; transform: translateX(-100%); transition: transform .18s; }
.sidebar.open { transform: none; box-shadow: 20px 0 60px rgba(0, 0, 0, .5); }
.menu { display: inline-block; }
.topbar { padding: 14px 16px; }
diff --git a/Panel/app.js b/Panel/app.js
index d2c9c2c..c1a7175 100644
--- a/Panel/app.js
+++ b/Panel/app.js
@@ -165,23 +165,32 @@ $("menuToggle").addEventListener("click", () => document.querySelector(".sidebar
// ------------------------------------------------------------------ Navigation
const PAGES = [
- { id: "dashboard", label: "Dashboard", icon: "◧", role: "Moderator", render: renderDashboard },
- { id: "players", label: "Players", icon: "☺", role: "Moderator", render: renderPlayers },
- { id: "console", label: "Console", icon: "›_", role: "Moderator", render: renderConsole },
- { id: "bans", label: "Bans", icon: "⊘", role: "Moderator", render: renderBans },
- { id: "world", label: "World & Backups", icon: "⟲", role: "Admin", render: renderWorld },
- { id: "settings", label: "Settings", icon: "☰", role: "Admin", render: renderSettings },
- { id: "users", label: "Panel Users", icon: "⚿", role: "Owner", render: renderUsers },
- { id: "account", label: "My Account", icon: "⚙", role: "Moderator", render: renderAccount },
+ { id: "dashboard", label: "Dashboard", icon: "◧", role: "Moderator", group: "Server", render: renderDashboard },
+ { id: "players", label: "Players", icon: "☺", role: "Moderator", group: "Server", render: renderPlayers },
+ { id: "console", label: "Console", icon: "›_", role: "Moderator", group: "Server", render: renderConsole },
+ { id: "admins", label: "Admins", icon: "★", role: "Moderator", group: "Moderation", render: renderAdmins },
+ { id: "bans", label: "Bans", icon: "⊘", role: "Moderator", group: "Moderation", render: renderBans },
+ { id: "world", label: "World & Backups", icon: "⟲", role: "Admin", group: "Management", render: renderWorld },
+ { id: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings },
+ { id: "users", label: "Panel Users", icon: "⚿", role: "Owner", group: "Management", render: renderUsers },
+ { id: "account", label: "My Account", icon: "☻", role: "Moderator", group: null, render: renderAccount },
];
function renderNav() {
const nav = $("nav");
- nav.replaceChildren(...PAGES.filter((p) => can(p.role)).map((p) =>
- h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) },
- h("span", { class: "ico" }, p.icon), p.label)));
+ const items = [];
+ let group;
+ for (const p of PAGES.filter((x) => can(x.role) && x.group)) {
+ if (p.group !== group) { group = p.group; items.push(h("div", { class: "nav-group" }, group)); }
+ items.push(h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) },
+ h("span", { class: "ico" }, p.icon), h("span", { class: "nav-label" }, p.label)));
+ }
+ nav.replaceChildren(...items);
+ $("meButton").classList.toggle("active", state.page === "account");
}
+$("meButton").addEventListener("click", () => go("account"));
+
function go(pageId) {
const p = PAGES.find((x) => x.id === pageId && can(x.role)) || PAGES[0];
state.page = p.id;
@@ -205,6 +214,9 @@ async function refreshTopbar() {
? `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}`
: `server ${s.state} · v${s.version}`;
$("statusDot").className = "status-dot " + (running ? "ok" : s.state === "error" ? "bad" : "");
+ $("scName").textContent = s.name;
+ $("scState").textContent = running ? `${s.players} / ${s.maxPlayers} players online` : "Server " + s.state;
+ $("scDot").className = "sc-dot " + (running ? "ok" : s.state === "error" ? "bad" : "");
$("statusDot").title = running ? "Online" : "Server " + s.state + (s.lastError ? ": " + s.lastError : "");
document.title = `${s.name} · MSCO Panel`;
} catch {
@@ -240,7 +252,7 @@ function renderDashboard(page) {
card("Uptime", duration(s.uptimeSeconds), "version " + s.version),
card("World", s.objects.toLocaleString(), `moved objects · ${s.actions} part actions · ${s.boughtItems} bought items`),
card("Save game", s.saveSyncMinutes > 0 ? `every ${s.saveSyncMinutes} min` : "on join",
- s.resetPoint ? "reset point from " + new Date(s.resetPoint).toLocaleString() : "no reset point yet"),
+ s.resetPoint ? "reset point from " + new Date(s.resetPoint).toLocaleString(window.MSCO_LANG === "de" ? "de-DE" : "en-GB") : "no reset point yet"),
card("Backups", s.backups, "save game backups"),
card("Admins / Bans", `${s.admins} / ${s.bans}`, s.steamVerification ? "Steam verification on" : "Steam verification OFF (no Web API key)"));
} catch (e) { cards.replaceChildren(h("div", { class: "card" }, e.message)); }
@@ -330,10 +342,11 @@ function renderSettings(page) {
h("span", { class: "label-row" }, label, envNote(name), restart ? h("span", { class: "pill gray" }, "restart") : null),
hint ? h("small", { class: "hint" }, hint) : null));
};
- const secret = (name, label, isSet) => {
+ const secret = (name, label, isSet, link) => {
const input = h("input", { type: "password", placeholder: isSet ? "•••••••• (set - leave empty to keep)" : "not set", autocomplete: "new-password", disabled: !can("Owner") });
fields[name] = () => input.value;
- return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), h("span", { class: "pill gray" }, "restart")), input);
+ return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), h("span", { class: "pill gray" }, "restart")), input,
+ link ? h("small", { class: "hint" }, link) : null);
};
const save = async () => {
@@ -374,7 +387,9 @@ function renderSettings(page) {
h("div", { class: "section-head" }, h("h2", {}, "Keys"), h("span", { class: "muted" }, can("Owner") ? "only owners can change keys" : "owner only")),
h("div", { class: "section-body form-grid wide" },
secret("licenseKey", "License key", c.licenseKeySet),
- secret("steamWebApiKey", "Steam Web API key", c.steamWebApiKeySet))),
+ secret("steamWebApiKey", "Steam Web API key", c.steamWebApiKeySet,
+ h("span", {}, "Needed for admins (verifies Steam accounts). ",
+ h("a", { href: "https://steamcommunity.com/dev/apikey", target: "_blank", rel: "noopener noreferrer" }, "Get your free key on Steam ↗"))))),
h("div", { class: "row" }, h("button", { class: "btn primary", type: "button", onclick: save }, "Save settings"),
h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart / start the server now?",
text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply")));
@@ -512,6 +527,51 @@ function renderConsole(page) {
if (can("Admin")) input.focus();
}
+// ------------------------------------------------------------------ Ingame-Admins
+
+function renderAdmins(page) {
+ const list = h("tbody");
+ const warning = h("div");
+ const id = h("input", { placeholder: "SteamID64 (17 digits)", maxlength: 17, inputmode: "numeric" });
+ const add = async () => {
+ if (!/^\d{17}$/.test(id.value.trim())) { toast("Enter a SteamID64 (17 digits)", true); return; }
+ if (await act(api("POST", "admins/add", { id: id.value.trim() }))) { id.value = ""; load(); }
+ };
+ id.addEventListener("keydown", (e) => { if (e.key === "Enter") add(); });
+ page.append(
+ warning,
+ can("Admin") ? h("div", { class: "section" },
+ h("div", { class: "section-head" }, h("h2", {}, "Add an admin"), h("span", { class: "muted" }, "also works when the player is offline")),
+ h("div", { class: "section-body row" }, id, h("button", { class: "btn primary", type: "button", onclick: add }, "Add admin")),
+ h("div", { class: "section-body muted small-text" }, "Find a SteamID64 on the player's Steam profile or with ",
+ h("a", { href: "https://steamid.io", target: "_blank", rel: "noopener noreferrer" }, "steamid.io ↗"),
+ ". Online players can also be made admin on the Players page.")) : null,
+ h("div", { class: "section" },
+ h("div", { class: "section-head" }, h("h2", {}, "In-game admins"), h("span", { class: "muted" }, "can use /admin in the game: kick, ban, teleport, items, world")),
+ h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "SteamID"), h("th", {}, "Player"), h("th", {}, ""))), list))));
+
+ async function load() {
+ try {
+ const r = await api("GET", "admins");
+ warning.replaceChildren(r.steamVerification ? "" : h("div", { class: "notice bad" },
+ h("div", {}, h("b", {}, "Admin rights are disabled"),
+ h("div", {}, "Without a Steam Web API key the server cannot verify Steam accounts - anyone could pretend to be an admin.")),
+ h("div", { class: "row" },
+ h("a", { class: "btn small primary", href: "https://steamcommunity.com/dev/apikey", target: "_blank", rel: "noopener noreferrer" }, "Get a Steam key ↗"),
+ can("Owner") ? h("button", { class: "btn small", type: "button", onclick: () => go("settings") }, "Enter it in Settings") : null)));
+ if (!r.admins.length) { list.replaceChildren(h("tr", {}, h("td", { colspan: 3, class: "empty" }, "No admins yet."))); return; }
+ list.replaceChildren(...r.admins.map((a) => h("tr", {},
+ h("td", {}, h("a", { href: "https://steamcommunity.com/profiles/" + a.steamId, target: "_blank", rel: "noopener noreferrer", class: "mono" }, a.steamId)),
+ h("td", {}, a.online ? h("span", { class: "player-name" }, a.name, h("span", { class: "pill green" }, "online")) : h("span", { class: "muted" }, "offline")),
+ h("td", {}, can("Admin") ? h("div", { class: "actions" }, h("button", { class: "btn small danger", type: "button", onclick: async () => {
+ const ok = await modal({ title: "Remove admin " + (a.name || a.steamId) + "?", danger: true, confirm: "Remove" });
+ if (ok && await act(api("POST", "admins/remove", { id: a.steamId }))) load();
+ } }, "Remove")) : null))));
+ } catch (e) { toast(e.message, true); }
+ }
+ every(10000, load);
+}
+
// ------------------------------------------------------------------ Banns
function renderBans(page) {
@@ -549,16 +609,16 @@ function renderBans(page) {
function renderWorld(page) {
const backups = h("tbody");
- const zipInput = h("input", { type: "file", accept: ".zip,application/zip" });
+ const zipInput = h("input", { type: "file", accept: ".zip,.rar,.7z,application/zip,application/x-rar-compressed,application/vnd.rar,application/x-7z-compressed" });
const uploadZip = async () => {
const file = zipInput.files && zipInput.files[0];
- if (!file) { toast("Choose a ZIP file first", true); return; }
+ if (!file) { toast("Choose a ZIP, RAR or 7z file first", true); return; }
const ok = await modal({ title: "Upload " + file.name + "?", danger: true, confirm: "Upload",
text: "The current save game is backed up, then replaced. All changes in the world are reset and everyone reconnects." });
if (!ok) return;
try {
const res = await fetch("api/save/upload", { method: "POST", credentials: "same-origin",
- headers: { "Content-Type": "application/zip", "X-MSCO-Panel": "1" }, body: file });
+ headers: { "Content-Type": "application/octet-stream", "X-MSCO-Panel": "1" }, body: file });
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status);
toast(data.message);
@@ -576,7 +636,7 @@ function renderWorld(page) {
};
page.append(
- h("div", { class: "cards" },
+ h("div", { class: "cards wide" },
h("div", { class: "card" },
h("div", { class: "stat-label" }, "Reset world"),
h("p", { class: "muted" }, "Everything goes back to the reset point (the save game as it was uploaded to the server). The current state is backed up first. Everyone reconnects."),
@@ -590,8 +650,8 @@ function renderWorld(page) {
h("p", { class: "muted" }, "Use the current save game as the new reset point for “Reset world”."),
h("button", { class: "btn", type: "button", onclick: () => worldAction("resetpoint", { title: "Set a new reset point?", text: "“Reset world” will go back to the save game as it is right now.", button: "Set reset point" }) }, "Set reset point")),
h("div", { class: "card" },
- h("div", { class: "stat-label" }, "Upload save game (ZIP)"),
- h("p", { class: "muted" }, "ZIP the files from AppData\\LocalLow\\Amistech\\My Summer Car. They replace the server save and become the reset point. Everyone reconnects."),
+ h("div", { class: "stat-label" }, "Upload save game"),
+ h("p", { class: "muted" }, "Pack the files from AppData\\LocalLow\\Amistech\\My Summer Car as ZIP, RAR or 7z. They replace the server save and become the reset point. Everyone reconnects."),
h("div", { class: "row" }, zipInput,
h("button", { class: "btn primary", type: "button", onclick: uploadZip }, "Upload"),
h("a", { class: "btn ghost", href: "api/save/download" }, "Download current"))),
@@ -702,6 +762,8 @@ async function boot() {
$("app").classList.remove("hidden");
$("meName").textContent = session.user.name;
$("meRole").textContent = session.user.role;
+ $("meAvatar").textContent = session.user.name.slice(0, 1).toUpperCase();
+ $("scName").textContent = session.serverName;
$("serverName").textContent = session.serverName;
let page = "dashboard";
try { page = localStorage.getItem("msco-panel-page") || page; } catch { /* egal */ }
diff --git a/Panel/i18n.js b/Panel/i18n.js
new file mode 100644
index 0000000..168a7ab
--- /dev/null
+++ b/Panel/i18n.js
@@ -0,0 +1,419 @@
+"use strict";
+// Sprache des Panels (Englisch / Deutsch). Die Oberfläche wird auf Englisch gebaut; diese Schicht übersetzt Texte,
+// Platzhalter und Tooltips, sobald sie auf der Seite erscheinen. Spielernamen, SteamIDs, der Servername und das
+// Konsolen-Log werden nie übersetzt.
+(function () {
+ const KEY = "msco-panel-lang";
+ let lang = "en";
+ try {
+ lang = localStorage.getItem(KEY) || ((navigator.language || "").toLowerCase().startsWith("de") ? "de" : "en");
+ } catch { /* egal */ }
+ if (lang !== "de") lang = "en";
+ document.documentElement.lang = lang;
+ window.MSCO_LANG = lang;
+
+ const DE = {
+ // Anmeldung
+ "Sign in": "Anmelden",
+ "Set up the panel": "Panel einrichten",
+ "Create account": "Konto anlegen",
+ "Setup code": "Einrichtungs-Code",
+ "Username": "Benutzername",
+ "Password": "Passwort",
+ "Repeat password": "Passwort wiederholen",
+ "Server Panel": "Server-Panel",
+ "Create the owner account. The setup code is shown in the server console (docker logs msco-server).":
+ "Lege das Owner-Konto an. Den Einrichtungs-Code findest du in der Server-Konsole (docker logs msco-server).",
+ "The passwords do not match": "Die Passwörter stimmen nicht überein",
+ "Log out": "Abmelden",
+ "Menu": "Menü",
+ "My Account": "Mein Konto",
+ "My account": "Mein Konto",
+ "Server status": "Serverstatus",
+ // Navigation
+ "Server": "Server",
+ "Moderation": "Moderation",
+ "Management": "Verwaltung",
+ "Dashboard": "Übersicht",
+ "Players": "Spieler",
+ "Console": "Konsole",
+ "Admins": "Admins",
+ "Bans": "Banns",
+ "World & Backups": "Welt & Backups",
+ "Settings": "Einstellungen",
+ "Panel Users": "Panel-Benutzer",
+ "Panel users": "Panel-Benutzer",
+ // Dashboard
+ "Running - players can join": "Läuft - Spieler können beitreten",
+ "Starting …": "Startet …",
+ "Stopped - players cannot join. The panel keeps running.": "Gestoppt - niemand kann beitreten. Das Panel läuft weiter.",
+ "Not running": "Läuft nicht",
+ "▶ Start": "▶ Starten",
+ "■ Stop": "■ Stoppen",
+ "⟳ Restart": "⟳ Neu starten",
+ "Stop the server?": "Server stoppen?",
+ "All players are disconnected and nobody can join until you start it again. The panel keeps running.":
+ "Alle Spieler werden getrennt und niemand kann beitreten, bis du ihn wieder startest. Das Panel läuft weiter.",
+ "Stop": "Stoppen",
+ "Restart the server?": "Server neu starten?",
+ "Settings (server.json), license and save game are loaded again. Players reconnect automatically.":
+ "Einstellungen (server.json), Lizenz und Spielstand werden neu geladen. Spieler verbinden sich automatisch neu.",
+ "Restart": "Neu starten",
+ "The server could not start": "Der Server konnte nicht starten",
+ "Note": "Hinweis",
+ "Enter license key": "Lizenzschlüssel eintragen",
+ "Upload save game": "Spielstand hochladen",
+ "Open console": "Konsole öffnen",
+ "Recent activity": "Letzte Aktivität",
+ "Uptime": "Laufzeit",
+ "World": "Welt",
+ "Save game": "Spielstand",
+ "Backups": "Backups",
+ "Admins / Bans": "Admins / Banns",
+ "listed in the server browser": "in der Serverliste sichtbar",
+ "not listed (private)": "nicht gelistet (privat)",
+ "on join": "beim Beitreten",
+ "no reset point yet": "noch kein Reset-Punkt",
+ "save game backups": "Spielstand-Backups",
+ "Steam verification on": "Steam-Prüfung an",
+ "Steam verification OFF (no Web API key)": "Steam-Prüfung AUS (kein Web-API-Key)",
+ // Spieler
+ "ID": "ID",
+ "Name": "Name",
+ "SteamID": "SteamID",
+ "Address": "Adresse",
+ "Online": "Online",
+ "Nobody is online right now.": "Gerade ist niemand online.",
+ "Server message": "Nachricht an alle",
+ "Message to all players…": "Nachricht an alle Spieler …",
+ "Send": "Senden",
+ "Kick": "Kicken",
+ "Ban": "Bannen",
+ "Make admin": "Zum Admin machen",
+ "Remove admin": "Admin entfernen",
+ "ADMIN": "ADMIN",
+ "unverified": "nicht bestätigt",
+ "SteamID not verified by Steam": "SteamID nicht von Steam bestätigt",
+ "The player is kicked and cannot join again until you unban them.": "Der Spieler fliegt raus und kann erst wieder beitreten, wenn du ihn entbannst.",
+ "The player sees the reason and can join again.": "Der Spieler sieht den Grund und kann wieder beitreten.",
+ "Reason (shown to the player)": "Grund (sieht der Spieler)",
+ "optional": "optional",
+ "In-game admins can kick, ban, spawn items and reset the world.": "Ingame-Admins können kicken, bannen, Items spawnen und die Welt zurücksetzen.",
+ "Remove": "Entfernen",
+ "Cancel": "Abbrechen",
+ "OK": "OK",
+ // Konsole
+ "Live console": "Live-Konsole",
+ "chat, joins, admin actions and command output": "Chat, Beitritte, Admin-Aktionen und Befehlsausgaben",
+ "Command… (help for a list)": "Befehl … (help für eine Liste)",
+ "Only admins can enter commands": "Nur Admins können Befehle eingeben",
+ "Run": "Ausführen",
+ // Admins
+ "Add an admin": "Admin hinzufügen",
+ "also works when the player is offline": "geht auch, wenn der Spieler offline ist",
+ "SteamID64 (17 digits)": "SteamID64 (17 Ziffern)",
+ "Add admin": "Admin hinzufügen",
+ "Find a SteamID64 on the player's Steam profile or with ": "Die SteamID64 findest du im Steam-Profil des Spielers oder mit ",
+ ". Online players can also be made admin on the Players page.": ". Spieler, die online sind, kannst du auch auf der Spieler-Seite zum Admin machen.",
+ "In-game admins": "Ingame-Admins",
+ "can use /admin in the game: kick, ban, teleport, items, world": "dürfen im Spiel /admin benutzen: kicken, bannen, teleportieren, Items, Welt",
+ "Player": "Spieler",
+ "offline": "offline",
+ "online": "online",
+ "No admins yet.": "Noch keine Admins.",
+ "Admin rights are disabled": "Admin-Rechte sind ausgeschaltet",
+ "Without a Steam Web API key the server cannot verify Steam accounts - anyone could pretend to be an admin.":
+ "Ohne Steam-Web-API-Key kann der Server Steam-Konten nicht prüfen - jeder könnte sich als Admin ausgeben.",
+ "Get a Steam key ↗": "Steam-Key holen ↗",
+ "Enter it in Settings": "In den Einstellungen eintragen",
+ "Enter a SteamID64 (17 digits)": "Gib eine SteamID64 ein (17 Ziffern)",
+ // Banns
+ "Ban a SteamID": "SteamID bannen",
+ "SteamID64 (17 digits) or name:playername": "SteamID64 (17 Ziffern) oder name:spielername",
+ "Banned players": "Gebannte Spieler",
+ "Identity": "Identität",
+ "Nobody is banned.": "Niemand ist gebannt.",
+ "Unban": "Entbannen",
+ // Welt & Backups
+ "Reset world": "Welt zurücksetzen",
+ "Everything goes back to the reset point (the save game as it was uploaded to the server). The current state is backed up first. Everyone reconnects.":
+ "Alles geht zurück auf den Reset-Punkt (den Spielstand, wie er auf den Server hochgeladen wurde). Der aktuelle Stand wird vorher gesichert. Alle verbinden sich neu.",
+ "Reset the whole world?": "Die ganze Welt zurücksetzen?",
+ "Moved objects, bought items, doors, vehicle values and player positions are reset to the reset point. The current state is kept as a backup.":
+ "Bewegte Objekte, gekaufte Items, Türen, Fahrzeugwerte und Spielerpositionen gehen zurück auf den Reset-Punkt. Der aktuelle Stand bleibt als Backup erhalten.",
+ "Backup": "Backup",
+ "Save a copy of the current server save game. The last 5 backups are kept.": "Sichert eine Kopie des aktuellen Server-Spielstands. Die letzten 5 Backups bleiben erhalten.",
+ "Backup now": "Jetzt sichern",
+ "Reset point": "Reset-Punkt",
+ "Use the current save game as the new reset point for “Reset world”.": "Den aktuellen Spielstand als neuen Reset-Punkt für „Welt zurücksetzen“ nehmen.",
+ "Set reset point": "Reset-Punkt setzen",
+ "Set a new reset point?": "Neuen Reset-Punkt setzen?",
+ "“Reset world” will go back to the save game as it is right now.": "„Welt zurücksetzen“ geht dann auf den Spielstand von jetzt zurück.",
+ "Pack the files from AppData\\LocalLow\\Amistech\\My Summer Car as ZIP, RAR or 7z. They replace the server save and become the reset point. Everyone reconnects.":
+ "Pack die Dateien aus AppData\\LocalLow\\Amistech\\My Summer Car als ZIP, RAR oder 7z. Sie ersetzen den Server-Spielstand und werden der Reset-Punkt. Alle verbinden sich neu.",
+ "Upload": "Hochladen",
+ "Download current": "Aktuellen herunterladen",
+ "Choose a ZIP, RAR or 7z file first": "Wähle zuerst eine ZIP-, RAR- oder 7z-Datei",
+ "The current save game is backed up, then replaced. All changes in the world are reset and everyone reconnects.":
+ "Der aktuelle Spielstand wird gesichert und dann ersetzt. Alle Änderungen in der Welt werden zurückgesetzt, alle verbinden sich neu.",
+ "Save game files": "Spielstand-Dateien",
+ "Copied new save files into data/save? Load them (they also become the reset point).": "Neue Spielstand-Dateien nach data/save kopiert? Hier laden (sie werden auch der Reset-Punkt).",
+ "Reload save": "Spielstand laden",
+ "Save world state": "Weltzustand speichern",
+ "Load the save game from data/save?": "Spielstand aus data/save laden?",
+ "Everyone reconnects and gets this save game.": "Alle verbinden sich neu und bekommen diesen Spielstand.",
+ "Load": "Laden",
+ "Save game backups": "Spielstand-Backups",
+ "newest first": "neueste zuerst",
+ "Created": "Erstellt",
+ "Folder": "Ordner",
+ "No backups yet.": "Noch keine Backups.",
+ "Restore": "Wiederherstellen",
+ "The server goes back to this save game (the current one is backed up first). Everyone reconnects.":
+ "Der Server geht auf diesen Spielstand zurück (der aktuelle wird vorher gesichert). Alle verbinden sich neu.",
+ // Einstellungen
+ "General": "Allgemein",
+ "applies immediately": "gilt sofort",
+ "Server name": "Servername",
+ "Description": "Beschreibung",
+ "Max players": "Max. Spieler",
+ "Public address": "Öffentliche Adresse",
+ "automatic": "automatisch",
+ "IP or domain players connect to (empty = automatic)": "IP oder Domain, zu der Spieler verbinden (leer = automatisch)",
+ "Listed in the server browser": "In der Serverliste anzeigen",
+ "Steam required": "Steam erforderlich",
+ "Off = players without Steam can join too": "Aus = auch Spieler ohne Steam können beitreten",
+ "Saving": "Speichern",
+ "Take the save game from a player every … minutes": "Spielstand alle … Minuten von einem Spieler holen",
+ "0 = only when someone joins": "0 = nur wenn jemand beitritt",
+ "Write the world state every … seconds": "Weltzustand alle … Sekunden speichern",
+ "Network & panel": "Netzwerk & Panel",
+ "needs a restart": "braucht einen Neustart",
+ "restart": "Neustart",
+ "Game port": "Spiel-Port",
+ "Panel port": "Panel-Port",
+ "0 = same port as the game (recommended)": "0 = derselbe Port wie das Spiel (empfohlen)",
+ "Panel bind address": "Panel-Bind-Adresse",
+ "only with an own panel port - 127.0.0.1 = only via a local reverse proxy": "nur mit eigenem Panel-Port - 127.0.0.1 = nur über einen lokalen Reverse-Proxy",
+ "Panel sub path": "Panel-Unterpfad",
+ "e.g. /panel when the proxy serves it there": "z.B. /panel, wenn der Proxy es dort ausliefert",
+ "Behind a reverse proxy": "Hinter einem Reverse-Proxy",
+ "Trust X-Forwarded-For / -Proto / -Prefix": "X-Forwarded-For / -Proto / -Prefix vertrauen",
+ "Web panel enabled": "Web-Panel eingeschaltet",
+ "Turning it off locks you out of this panel after the next program start": "Ausschalten sperrt dich nach dem nächsten Programmstart aus diesem Panel aus",
+ "Keys": "Schlüssel",
+ "only owners can change keys": "nur Owner können Schlüssel ändern",
+ "owner only": "nur Owner",
+ "License key": "Lizenzschlüssel",
+ "Steam Web API key": "Steam-Web-API-Key",
+ "Needed for admins (verifies Steam accounts). ": "Nötig für Admins (prüft Steam-Konten). ",
+ "Get your free key on Steam ↗": "Kostenlosen Key bei Steam holen ↗",
+ "not set": "nicht gesetzt",
+ "•••••••• (set - leave empty to keep)": "•••••••• (gesetzt - leer lassen zum Behalten)",
+ "Also set by an environment variable (.env) - that value wins on restart": "Auch per Umgebungsvariable (.env) gesetzt - dieser Wert gilt nach einem Neustart",
+ "Save settings": "Einstellungen speichern",
+ "Restart to apply": "Neu starten zum Übernehmen",
+ "Restart / start the server now?": "Server jetzt neu starten / starten?",
+ "Loads the saved settings. Players reconnect automatically.": "Lädt die gespeicherten Einstellungen. Spieler verbinden sich automatisch neu.",
+ // Benutzer
+ "+ Add user": "+ Benutzer hinzufügen",
+ "Role": "Rolle",
+ "Last login": "Letzte Anmeldung",
+ "never": "nie",
+ "you": "du",
+ "Password (min. 8 characters)": "Passwort (mind. 8 Zeichen)",
+ "Add panel user": "Panel-Benutzer hinzufügen",
+ "Create": "Anlegen",
+ "Save": "Speichern",
+ "Set password": "Passwort setzen",
+ "New password": "Neues Passwort",
+ "They are logged out everywhere.": "Er wird überall abgemeldet.",
+ "They can no longer log in to the panel.": "Er kann sich dann nicht mehr im Panel anmelden.",
+ "Delete": "Löschen",
+ ": players, kick, server messages, read console & bans. ": ": Spieler, kicken, Nachrichten, Konsole & Banns lesen. ",
+ ": everything on the server (ban, console commands, world reset, backups). ": ": alles am Server (bannen, Konsolenbefehle, Welt-Reset, Backups). ",
+ ": additionally manages panel users.": ": verwaltet zusätzlich die Panel-Benutzer.",
+ // Konto
+ "Current password": "Aktuelles Passwort",
+ "New password (min. 8 characters)": "Neues Passwort (mind. 8 Zeichen)",
+ "Repeat new password": "Neues Passwort wiederholen",
+ "Change password": "Passwort ändern",
+ "The new passwords do not match": "Die neuen Passwörter stimmen nicht überein",
+ // Meldungen vom Server
+ "Please log in": "Bitte anmelden",
+ "Wrong username or password": "Falscher Benutzername oder falsches Passwort",
+ "Too many failed logins - wait a few minutes": "Zu viele Fehlversuche - warte ein paar Minuten",
+ "Too many attempts - wait a few minutes": "Zu viele Versuche - warte ein paar Minuten",
+ "Wrong setup code (see the server console / docker logs)": "Falscher Einrichtungs-Code (siehe Server-Konsole / docker logs)",
+ "The game server is not running - start it on the dashboard": "Der Gameserver läuft nicht - starte ihn in der Übersicht",
+ "The server is busy or stopping": "Der Server ist beschäftigt oder stoppt gerade",
+ "Starting the server": "Server wird gestartet",
+ "Stopping the server - the panel keeps running": "Server wird gestoppt - das Panel läuft weiter",
+ "Restarting - the server is back in a few seconds": "Neustart - der Server ist in ein paar Sekunden wieder da",
+ "The server is not running": "Der Server läuft nicht",
+ "Nothing changed": "Nichts geändert",
+ "Sent": "Gesendet",
+ "Password changed": "Passwort geändert",
+ "Done": "Erledigt",
+ "Backup created": "Backup erstellt",
+ "World state saved": "Weltzustand gespeichert",
+ "World reset": "Welt zurückgesetzt",
+ "World reset to the original save game": "Welt auf den ursprünglichen Spielstand zurückgesetzt",
+ "World reset to the reset point": "Welt auf den Reset-Punkt zurückgesetzt",
+ "The current save game is now the reset point": "Der aktuelle Spielstand ist jetzt der Reset-Punkt",
+ "No save game to use as reset point": "Kein Spielstand für den Reset-Punkt vorhanden",
+ "Player not found": "Spieler nicht gefunden",
+ "Your role (Moderator) is not allowed to do this": "Deine Rolle (Moderator) darf das nicht",
+ "Your role (Admin) is not allowed to do this": "Deine Rolle (Admin) darf das nicht",
+ "Only the owner can change keys": "Nur der Owner kann Schlüssel ändern",
+ "You cannot delete yourself": "Du kannst dich nicht selbst löschen",
+ "The last owner cannot be deleted": "Der letzte Owner kann nicht gelöscht werden",
+ "There must be at least one owner": "Es muss mindestens einen Owner geben",
+ "A user with this name already exists": "Diesen Benutzernamen gibt es schon",
+ "Password: at least 8 characters": "Passwort: mindestens 8 Zeichen",
+ "Username: 3-32 characters (letters, numbers, _ . -)": "Benutzername: 3-32 Zeichen (Buchstaben, Zahlen, _ . -)",
+ "Current password is wrong": "Das aktuelle Passwort ist falsch",
+ "The server name cannot be empty": "Der Servername darf nicht leer sein",
+ "There is no save game on the server yet": "Auf dem Server gibt es noch keinen Spielstand",
+ "The archive is password protected": "Das Archiv ist passwortgeschützt",
+ "The save game is too large": "Der Spielstand ist zu groß",
+ "Enter a command": "Gib einen Befehl ein",
+ "Choose a ZIP, RAR or 7z file": "Wähle eine ZIP-, RAR- oder 7z-Datei",
+ };
+
+ // Zusammengesetzte Texte (Name, Zahl ... darin)
+ const PATTERNS = [
+ [/^Kick (.+)$/, "$1 kicken"],
+ [/^Ban (.+)$/, "$1 bannen"],
+ [/^Make (.+) an admin\?$/, "$1 zum Admin machen?"],
+ [/^Remove admin: (.+)$/, "Admin entfernen: $1"],
+ [/^Remove admin (.+)\?$/, "Admin $1 entfernen?"],
+ [/^Upload (.+)\?$/, "$1 hochladen?"],
+ [/^Restore (.+)\?$/, "$1 wiederherstellen?"],
+ [/^Role of (.+)$/, "Rolle von $1"],
+ [/^New password for (.+)$/, "Neues Passwort für $1"],
+ [/^Delete (.+)\?$/, "$1 löschen?"],
+ [/^(.+) ago$/, "vor $1"],
+ [/^just now$/, "gerade eben"],
+ [/^version (.+)$/, "Version $1"],
+ [/^every (\d+) min$/, "alle $1 Min."],
+ [/^reset point from (.+)$/, "Reset-Punkt vom $1"],
+ [/^moved objects · (\d+) part actions · (\d+) bought items$/, "bewegte Objekte · $1 Teile-Aktionen · $2 gekaufte Items"],
+ [/^(\d+) \/ (\d+) players online$/, "$1 / $2 Spieler online"],
+ [/^(\d+)\/(\d+) players · v(.+) · up (.+)$/, "$1/$2 Spieler · v$3 · seit $4"],
+ [/^server (\w+) · v(.+)$/, (m, s, v) => "Server " + STATE[s] + " · v" + v],
+ [/^Server (running|stopped|starting|error)(: .*)?$/, (m, s, rest) => "Server " + STATE[s] + (rest ? ": " + tr1(rest.slice(2)) : "")],
+ [/^(.+) was kicked$/, "$1 wurde gekickt"],
+ [/^(.+) was banned$/, "$1 wurde gebannt"],
+ [/^(.+) is banned$/, "$1 ist gebannt"],
+ [/^(.+) is no longer banned$/, "$1 ist nicht mehr gebannt"],
+ [/^(.+) is already banned$/, "$1 ist schon gebannt"],
+ [/^SteamID (\d+) is now an admin$/, "SteamID $1 ist jetzt Admin"],
+ [/^SteamID (\d+) is no longer an admin$/, "SteamID $1 ist kein Admin mehr"],
+ [/^(.+) is now an admin$/, "$1 ist jetzt Admin"],
+ [/^(.+) is no longer an admin$/, "$1 ist kein Admin mehr"],
+ [/^User (.+) created$/, "Benutzer $1 angelegt"],
+ [/^Backup created: (.+)$/, "Backup erstellt: $1"],
+ [/^Restored (.+?)( - start the server to use it)?$/, (m, n, s) => n + " wiederhergestellt" + (s ? " - starte den Server, um ihn zu benutzen" : "")],
+ [/^Save game uploaded \((\d+) files\) - everyone reconnects$/, "Spielstand hochgeladen ($1 Dateien) - alle verbinden sich neu"],
+ [/^Save game uploaded \((\d+) files\) - start the server to use it$/, "Spielstand hochgeladen ($1 Dateien) - starte den Server, um ihn zu benutzen"],
+ [/^Settings saved(.*)$/, (m, rest) => "Einstellungen gespeichert" + rest.replace(" - some changes need a restart", " - manche Änderungen brauchen einen Neustart")],
+ [/^The server is already (\w+)$/, (m, s) => "Der Server " + (s === "running" ? "läuft schon" : "startet schon")],
+ [/^No save game yet - upload one as ZIP in the panel \(World & Backups\) or copy it to data\/save$/,
+ "Noch kein Spielstand - lade einen als ZIP/RAR im Panel hoch (Welt & Backups) oder kopiere ihn nach data/save"],
+ [/^No license key set - (.+)$/, "Kein Lizenzschlüssel gesetzt - hol dir einen unter msco.mbm.wtf/keys und trag ihn in den Einstellungen ein"],
+ [/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"],
+ [/^Master server not reachable (.+)$/, "Master-Server nicht erreichbar $1"],
+ [/^Crashed: (.+)$/, "Abgestürzt: $1"],
+ [/^This file could not be read - upload a ZIP, RAR or 7z archive (.+)$/, "Die Datei konnte nicht gelesen werden - lade ein ZIP-, RAR- oder 7z-Archiv hoch $1"],
+ [/^No defaultES2File\.txt in the archive(.*)$/, "Keine defaultES2File.txt im Archiv - pack die Dateien aus %USERPROFILE%\\AppData\\LocalLow\\Amistech\\My Summer Car"],
+ ];
+ const STATE = { running: "läuft", stopped: "gestoppt", starting: "startet", error: "Fehler" };
+
+ function tr1(text) {
+ if (Object.prototype.hasOwnProperty.call(DE, text)) return DE[text];
+ for (const [re, rep] of PATTERNS) if (re.test(text)) return text.replace(re, rep);
+ return null;
+ }
+
+ function translateText(value) {
+ if (lang === "en" || !value || !value.trim()) return null;
+ const lead = value.match(/^\s*/)[0], trail = value.match(/\s*$/)[0];
+ const core = value.slice(lead.length, value.length - trail.length);
+ let out = tr1(core);
+ if (out === null && Object.prototype.hasOwnProperty.call(DE, value)) out = DE[value]; // Texte mit Leerzeichen am Rand
+ if (out === null) return null;
+ return (DE[value] !== undefined ? "" : lead) + out + (DE[value] !== undefined ? "" : trail);
+ }
+
+ // Nutzerdaten nie übersetzen
+ const RAW = ".player-name, .mono, code, .console, #serverName, #scName, #meName, #authServer, .brand-title";
+ function isRaw(el) {
+ if (!el) return true;
+ if (el.closest(".console")) return true;
+ if (el.matches(RAW)) return true;
+ return false;
+ }
+
+ function translateNode(node) {
+ if (node.nodeType === 3) {
+ if (isRaw(node.parentElement)) return;
+ const out = translateText(node.nodeValue);
+ if (out !== null && out !== node.nodeValue) node.nodeValue = out;
+ return;
+ }
+ if (node.nodeType !== 1) return;
+ for (const attr of ["placeholder", "title", "aria-label"]) {
+ const v = node.getAttribute && node.getAttribute(attr);
+ if (!v) continue;
+ const out = translateText(v);
+ if (out !== null && out !== v) node.setAttribute(attr, out);
+ }
+ if (node.closest && node.closest(".console")) return;
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
+ if (n.nodeType === 3) translateNode(n);
+ else for (const attr of ["placeholder", "title", "aria-label"]) {
+ const v = n.getAttribute(attr);
+ if (!v) continue;
+ const out = translateText(v);
+ if (out !== null && out !== v) n.setAttribute(attr, out);
+ }
+ }
+ }
+
+ function renderSwitches() {
+ document.querySelectorAll("[data-lang-switch]").forEach((box) => {
+ box.replaceChildren();
+ for (const code of ["en", "de"]) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.textContent = code.toUpperCase();
+ b.className = code === lang ? "on" : "";
+ b.title = code === "de" ? "Deutsch" : "English";
+ b.addEventListener("click", () => {
+ if (code === lang) return;
+ try { localStorage.setItem(KEY, code); } catch { /* egal */ }
+ location.reload();
+ });
+ box.append(b);
+ }
+ });
+ }
+
+ function start() {
+ renderSwitches();
+ if (lang === "en") return;
+ translateNode(document.body);
+ new MutationObserver((mutations) => {
+ for (const m of mutations) {
+ if (m.type === "characterData") translateNode(m.target);
+ else if (m.type === "attributes") translateNode(m.target);
+ else m.addedNodes.forEach(translateNode);
+ }
+ }).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["placeholder", "title"] });
+ }
+
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
+ else start();
+})();
diff --git a/Panel/index.html b/Panel/index.html
index 8dd17ca..1d26651 100644
--- a/Panel/index.html
+++ b/Panel/index.html
@@ -6,11 +6,13 @@
MSCO Server Panel
+
+