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:
+56
-16
@@ -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; }
|
||||
|
||||
+83
-21
@@ -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 */ }
|
||||
|
||||
+419
@@ -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();
|
||||
})();
|
||||
+17
-2
@@ -6,11 +6,13 @@
|
||||
<title>MSCO Server Panel</title>
|
||||
<link rel="icon" href="logo.png">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
<script src="i18n.js" defer></script>
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Anmeldung / Einrichtung -->
|
||||
<main id="auth" class="auth hidden">
|
||||
<div class="auth-lang lang-switch" data-lang-switch></div>
|
||||
<form id="authForm" class="auth-card" autocomplete="on">
|
||||
<div class="brand">
|
||||
<img src="logo.png" alt="">
|
||||
@@ -48,10 +50,23 @@
|
||||
<div class="brand-sub">Server Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-card" id="serverCard">
|
||||
<span class="sc-dot" id="scDot"></span>
|
||||
<div class="sc-text">
|
||||
<b id="scName">MSCO Server</b>
|
||||
<small id="scState">…</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav id="nav"></nav>
|
||||
<div class="sidebar-foot">
|
||||
<div class="me"><span id="meName"></span><span id="meRole" class="pill"></span></div>
|
||||
<button class="btn ghost small" id="logout" type="button">Log out</button>
|
||||
<div class="lang-switch" data-lang-switch></div>
|
||||
<div class="me-card">
|
||||
<button class="me-main" id="meButton" type="button" title="My Account">
|
||||
<span class="avatar" id="meAvatar">?</span>
|
||||
<span class="me-text"><b id="meName"></b><small id="meRole"></small></span>
|
||||
</button>
|
||||
<button class="icon-btn" id="logout" type="button" title="Log out" aria-label="Log out">⏻</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="content">
|
||||
|
||||
Reference in New Issue
Block a user