Panel: German/English, redesigned sidebar, in-game admins page, Steam Web API key link, RAR/7z save upload (SharpCompress), responsive cards; upload errors return 400; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 19:40:46 +02:00
co-authored by Claude Opus 5.5
parent c74c48dfc8
commit 8df6cbcafc
12 changed files with 700 additions and 58 deletions
+83 -21
View File
@@ -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 */ }