Console panel, collapsible panels, Add on the Members panel
- Server settings > Console sends a raw console command for staff with @css/rcon (POST /api/admin/rcon). It doesn't wait for the server; the output is only logged. - Staff & ranks and Server settings panels fold up by clicking their heads. All but Ranks and Server start folded; a folded Ranks panel shows the selected rank. - The Members panel has an Add button that opens Add staff with that rank picked. - The public rank log shows a rank being given as Promoted and taken away as Demoted.
This commit is contained in:
parent
31901495de
commit
b26360e8f3
8 changed files with 123 additions and 24 deletions
|
|
@ -43,6 +43,7 @@ RCON. Without it the panel still works, but shows the server as offline and can'
|
||||||
| See IP addresses | `@css/showip` |
|
| See IP addresses | `@css/showip` |
|
||||||
| Change map | `@css/changemap` |
|
| Change map | `@css/changemap` |
|
||||||
| Server cvars | `@css/cvar` |
|
| Server cvars | `@css/cvar` |
|
||||||
|
| Raw console commands (Server settings > Console; sent without waiting, output only logged) | `@css/rcon` |
|
||||||
| See the Staff & ranks page | `@zio/webpanel` |
|
| See the Staff & ranks page | `@zio/webpanel` |
|
||||||
| Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) |
|
| Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) |
|
||||||
| Ranks, staff, SimpleAdmin settings, reload admins, GroupVerify's group and on/off | `@css/root` |
|
| Ranks, staff, SimpleAdmin settings, reload admins, GroupVerify's group and on/off | `@css/root` |
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,43 @@ func (s *Server) restartRound(w http.ResponseWriter, r *http.Request, id *Identi
|
||||||
s.console(w, id, "@css/generic", "css_rr", "Restarting the match.")
|
s.console(w, id, "@css/generic", "css_rr", "Restarting the match.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rcon runs a raw console command for staff with @css/rcon, the same as css_rcon in game. It doesn't
|
||||||
|
// wait for the server: the command is sent in the background and what it prints is only logged.
|
||||||
|
func (s *Server) rcon(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
|
if !id.Has("@css/rcon") {
|
||||||
|
fail(w, http.StatusForbidden, "Your rank can't do that.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
if err := readJSON(r, &req); err != nil {
|
||||||
|
fail(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Newlines would send several commands; ';' still does, as it does in the console.
|
||||||
|
cmd := strings.TrimSpace(strings.NewReplacer("\r", " ", "\n", " ").Replace(req.Command))
|
||||||
|
if cmd == "" {
|
||||||
|
fail(w, http.StatusBadRequest, "Enter a command first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cmd) > 512 {
|
||||||
|
fail(w, http.StatusBadRequest, "That command is too long.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("rcon", "admin", id.SteamID, "cmd", cmd)
|
||||||
|
go func() {
|
||||||
|
out, err := s.rc.Exec(cmd)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("rcon failed", "admin", id.SteamID, "cmd", cmd, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("rcon output", "admin", id.SteamID, "out", strings.TrimSpace(out))
|
||||||
|
s.poll.Refresh()
|
||||||
|
}()
|
||||||
|
done(w, "Sent.")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) reloadAdmins(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
func (s *Server) reloadAdmins(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
s.staff.invalidate()
|
s.staff.invalidate()
|
||||||
s.console(w, id, "@css/root", "css_reloadadmins", "Reloaded admins.")
|
s.console(w, id, "@css/root", "css_reloadadmins", "Reloaded admins.")
|
||||||
|
|
|
||||||
|
|
@ -397,7 +397,7 @@ func (s *Server) readCvar(key string) (string, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) adminSettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
func (s *Server) adminSettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
|
||||||
resp := map[string]any{"canCvar": id.Has("@css/cvar"), "canRoot": id.Has("@css/root")}
|
resp := map[string]any{"canCvar": id.Has("@css/cvar"), "canRoot": id.Has("@css/root"), "canRcon": id.Has("@css/rcon")}
|
||||||
|
|
||||||
if id.Has("@css/cvar") && s.poll.Current().Online {
|
if id.Has("@css/cvar") && s.poll.Current().Online {
|
||||||
defs := make([]cvarDef, 0, len(cvars))
|
defs := make([]cvarDef, 0, len(cvars))
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ const state = {
|
||||||
query: "",
|
query: "",
|
||||||
num: 1,
|
num: 1,
|
||||||
group: null,
|
group: null,
|
||||||
|
// Collapsed panels, see foldHead.
|
||||||
|
fold: { ranks: false, rank: true, members: true, sa: true, connections: true, maintenance: true, rcon: true },
|
||||||
me: null,
|
me: null,
|
||||||
counts: {},
|
counts: {},
|
||||||
};
|
};
|
||||||
|
|
@ -274,6 +276,15 @@ async function loadResults() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Collapsible panels ---------- */
|
||||||
|
|
||||||
|
// A collapsible panel's head: clicking anywhere on it but its own buttons opens or closes it.
|
||||||
|
// state.fold remembers which are closed until the page is reloaded; a panel not in it starts open.
|
||||||
|
const foldHead = (key, title, aside = "") => `<div class="panel-head fold-head" data-fold="${key}">
|
||||||
|
<h2><button class="fold-toggle" type="button" aria-expanded="${!state.fold[key]}">${ICON.chevron}${title}</button></h2>${aside}
|
||||||
|
</div>`;
|
||||||
|
const folded = (key) => (state.fold[key] ? " folded" : "");
|
||||||
|
|
||||||
/* ---------- Staff & ranks ---------- */
|
/* ---------- Staff & ranks ---------- */
|
||||||
|
|
||||||
async function staff() {
|
async function staff() {
|
||||||
|
|
@ -313,8 +324,8 @@ async function staff() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${d.groups.length ? `<div class="ranks-layout">
|
${d.groups.length ? `<div class="ranks-layout">
|
||||||
<section class="panel">
|
<section class="panel${folded("ranks")}">
|
||||||
<div class="panel-head"><h2>Ranks</h2><p>Immunity</p></div>
|
${foldHead("ranks", "Ranks", `<p class="fold-open">Immunity</p><p class="fold-shut"><span class="rank" style="--c:${esc(g.color)}"><i></i>${esc(g.label)}</span></p>`)}
|
||||||
<ul class="ladder">
|
<ul class="ladder">
|
||||||
${staffGroups.map(ladderItem).join("")}
|
${staffGroups.map(ladderItem).join("")}
|
||||||
${supporters.length ? `<li class="sep">Supporter ranks</li>${supporters.map(ladderItem).join("")}` : ""}
|
${supporters.length ? `<li class="sep">Supporter ranks</li>${supporters.map(ladderItem).join("")}` : ""}
|
||||||
|
|
@ -322,12 +333,9 @@ async function staff() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<section class="panel">
|
<section class="panel${folded("rank")}">
|
||||||
<div class="panel-head">
|
${foldHead("rank", `<span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span>`, `<span class="sid">${esc(g.name)}</span>`)}
|
||||||
<h2><span class="rank" style="--c:${esc(g.color)};font:inherit"><i></i>${esc(g.label)}</span></h2>
|
<form class="panel-body rank-form" id="group-form" data-id="${g.id}">
|
||||||
<span class="sid">${esc(g.name)}</span>
|
|
||||||
</div>
|
|
||||||
<form class="panel-body" id="group-form" style="display:grid;gap:18px" data-id="${g.id}">
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" value="${esc(g.name)}" ${manage ? "" : "readonly"} required><small>The display name and colour come from the panel's site config.</small></label>
|
<label class="field"><span>SimpleAdmin name</span><input class="input" name="name" value="${esc(g.name)}" ${manage ? "" : "readonly"} required><small>The display name and colour come from the panel's site config.</small></label>
|
||||||
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="9999" value="${g.immunity}" ${manage ? "" : "readonly"} required><small>0 to 9999</small></label>
|
<label class="field"><span>Immunity</span><input class="input" name="immunity" type="number" min="0" max="9999" value="${g.immunity}" ${manage ? "" : "readonly"} required><small>0 to 9999</small></label>
|
||||||
|
|
@ -350,8 +358,8 @@ async function staff() {
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel${folded("members")}">
|
||||||
<div class="panel-head"><h2>Members</h2><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p></div>
|
${foldHead("members", "Members", `<div style="display:flex;align-items:center;gap:12px"><p>${g.members.length} ${g.members.length === 1 ? "person" : "people"}</p>${manage ? `<button class="btn sm" type="button" data-act="add-staff" data-group="${esc(g.name)}">${ICON.plus}Add</button>` : ""}</div>`)}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
${g.members.length ? `<table><tbody>${g.members.map((p) => memberRow(p, g.name)).join("")}</tbody></table>`
|
${g.members.length ? `<table><tbody>${g.members.map((p) => memberRow(p, g.name)).join("")}</tbody></table>`
|
||||||
: `<div class="empty">Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}</div>`}
|
: `<div class="empty">Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}</div>`}
|
||||||
|
|
@ -540,28 +548,36 @@ async function settings() {
|
||||||
<div><h1>Server settings</h1><p class="sub">Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.</p></div>
|
<div><h1>Server settings</h1><p class="sub">Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
<section class="panel" id="cvars">
|
<section class="panel${folded("cvars")}" id="cvars">
|
||||||
<div class="panel-head"><h2>Server</h2><p>Live over RCON. Resets when the server restarts.</p></div>
|
${foldHead("cvars", "Server", "<p>Live over RCON. Resets when the server restarts.</p>")}
|
||||||
${!d.canCvar ? '<div class="empty">Your rank can\'t change server settings.</div>'
|
${!d.canCvar ? '<div class="empty">Your rank can\'t change server settings.</div>'
|
||||||
: !d.server.online ? '<div class="empty">The game server isn\'t answering, so these can\'t be read or changed right now.</div>'
|
: !d.server.online ? '<div class="empty">The game server isn\'t answering, so these can\'t be read or changed right now.</div>'
|
||||||
: (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
|
: (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="sa">
|
<section class="panel${folded("sa")}" id="sa">
|
||||||
<div class="panel-head"><h2>Punishments</h2><p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p></div>
|
${foldHead("sa", "Punishments", `<p>${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}</p>`)}
|
||||||
${!sa.available ? '<div class="empty">The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.</div>'
|
${!sa.available ? '<div class="empty">The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.</div>'
|
||||||
: sa.error ? `<div class="empty">${esc(sa.error)}</div>`
|
: sa.error ? `<div class="empty">${esc(sa.error)}</div>`
|
||||||
: sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
|
: sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
${d.canRcon ? `<section class="panel${folded("rcon")}">
|
||||||
<div class="panel-head"><h2>Connections</h2></div>
|
${foldHead("rcon", "Console", "<p>Runs as the server console. Doesn't wait for the server's reply.</p>")}
|
||||||
|
<form class="panel-body rcon-form" id="rcon-form" autocomplete="off">
|
||||||
|
<input class="input" name="command" maxlength="512" required placeholder="Console command, like mp_restartgame 1" aria-label="Console command" spellcheck="false">
|
||||||
|
<button class="btn primary" type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
</section>` : ""}
|
||||||
|
|
||||||
|
<section class="panel${folded("connections")}">
|
||||||
|
${foldHead("connections", "Connections")}
|
||||||
${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not connected</span>')}
|
${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not connected</span>')}
|
||||||
${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not answering</span>')}
|
${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? '<span class="tag green">Connected</span>' : '<span class="tag red">Not answering</span>')}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel${folded("maintenance")}">
|
||||||
<div class="panel-head"><h2>Maintenance</h2><p>Each of these asks first.</p></div>
|
${foldHead("maintenance", "Maintenance", "<p>Each of these asks first.</p>")}
|
||||||
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '<button class="btn" type="button" data-act="reload-admins">Reload admins</button>') : ""}
|
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '<button class="btn" type="button" data-act="reload-admins">Reload admins</button>') : ""}
|
||||||
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '<button class="btn" type="button" data-act="restart">Restart match</button>') : ""}
|
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '<button class="btn" type="button" data-act="restart">Restart match</button>') : ""}
|
||||||
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:<id>.", "", '<button class="btn" type="button" data-act="map">Change map</button>') : ""}
|
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:<id>.", "", '<button class="btn" type="button" data-act="map">Change map</button>') : ""}
|
||||||
|
|
@ -709,12 +725,13 @@ function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } =
|
||||||
</form>`);
|
</form>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rankDialog(sid, name, current) {
|
// preset picks the rank for someone new (current is their rank now, "" if they have none).
|
||||||
|
async function rankDialog(sid, name, current, preset = "") {
|
||||||
if (!staff.data) {
|
if (!staff.data) {
|
||||||
try { staff.data = await api("/api/admin/staff"); } catch (err) { toast(err.message); return; }
|
try { staff.data = await api("/api/admin/staff"); } catch (err) { toast(err.message); return; }
|
||||||
}
|
}
|
||||||
const groups = staff.data.groups;
|
const groups = staff.data.groups;
|
||||||
const opts = (groups || []).map((g) => `<option value="${esc(g.name)}" ${g.name === current ? "selected" : ""}>${esc(g.label)} (${g.immunity})</option>`).join("");
|
const opts = (groups || []).map((g) => `<option value="${esc(g.name)}" ${g.name === (current || preset) ? "selected" : ""}>${esc(g.label)} (${g.immunity})</option>`).join("");
|
||||||
openModal(`
|
openModal(`
|
||||||
<form method="dialog" data-submit="rank" data-sid="${esc(sid)}" data-had="${esc(current)}">
|
<form method="dialog" data-submit="rank" data-sid="${esc(sid)}" data-had="${esc(current)}">
|
||||||
<div class="modal-head"><h2>${current ? "Change rank" : "Add staff"}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
|
<div class="modal-head"><h2>${current ? "Change rank" : "Add staff"}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
|
||||||
|
|
@ -878,6 +895,15 @@ document.addEventListener("click", (e) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fold = t.closest("[data-fold]");
|
||||||
|
if (fold && !t.closest("[data-act]")) {
|
||||||
|
const shut = !state.fold[fold.dataset.fold];
|
||||||
|
state.fold[fold.dataset.fold] = shut;
|
||||||
|
fold.closest(".panel").classList.toggle("folded", shut);
|
||||||
|
$(".fold-toggle", fold).setAttribute("aria-expanded", String(!shut));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const grp = t.closest("[data-group]:not([data-act])");
|
const grp = t.closest("[data-group]:not([data-act])");
|
||||||
if (grp) { state.group = Number(grp.dataset.group); staff(); return; }
|
if (grp) { state.group = Number(grp.dataset.group); staff(); return; }
|
||||||
|
|
||||||
|
|
@ -906,7 +932,7 @@ document.addEventListener("click", (e) => {
|
||||||
d.kind === "ban" ? "Unban" : "Lift", (form) => act("/api/admin/penalties/lift", "POST", { kind: d.kind, id: Number(d.id), reason: form.reason?.value || "" }));
|
d.kind === "ban" ? "Unban" : "Lift", (form) => act("/api/admin/penalties/lift", "POST", { kind: d.kind, id: Number(d.id), reason: form.reason?.value || "" }));
|
||||||
}
|
}
|
||||||
case "rank": return rankDialog(d.sid, d.name, d.group);
|
case "rank": return rankDialog(d.sid, d.name, d.group);
|
||||||
case "add-staff": return rankDialog("", "", "");
|
case "add-staff": return rankDialog("", "", "", d.group || "");
|
||||||
case "new-group": return newGroupDialog();
|
case "new-group": return newGroupDialog();
|
||||||
case "delete-group":
|
case "delete-group":
|
||||||
confirmDialog.reason = false;
|
confirmDialog.reason = false;
|
||||||
|
|
@ -957,6 +983,16 @@ document.addEventListener("submit", async (e) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.id === "rcon-form") {
|
||||||
|
// Not act(): re-rendering the page would throw away unsaved settings above.
|
||||||
|
try {
|
||||||
|
const res = await api("/api/admin/rcon", { method: "POST", body: { command: f.command.value } });
|
||||||
|
toast(res.message || "Sent.");
|
||||||
|
f.command.value = "";
|
||||||
|
} catch (err) { toast(err.message); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (form.id === "group-form") {
|
if (form.id === "group-form") {
|
||||||
const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value);
|
const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value);
|
||||||
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });
|
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const MIN = 60 * 1000;
|
||||||
|
|
||||||
const ICON = {
|
const ICON = {
|
||||||
search: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></svg>',
|
search: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3 3"/></svg>',
|
||||||
|
chevron: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg>',
|
||||||
plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
|
plus: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,12 +181,13 @@ async function staff() {
|
||||||
loadRankLog();
|
loadRankLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The database's added/removed are shown as promotions/demotions too: gaining or losing a rank.
|
||||||
const RANK_CHANGE = {
|
const RANK_CHANGE = {
|
||||||
added: ["Joined", "green"],
|
added: ["Promoted", "green"],
|
||||||
promoted: ["Promoted", "green"],
|
promoted: ["Promoted", "green"],
|
||||||
demoted: ["Demoted", "amber"],
|
demoted: ["Demoted", "amber"],
|
||||||
changed: ["Moved", ""],
|
changed: ["Moved", ""],
|
||||||
removed: ["Removed", "red"],
|
removed: ["Demoted", "amber"],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadRankLog() {
|
async function loadRankLog() {
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,24 @@ h3 { margin: 0; font: 600 17px/1.2 var(--display); letter-spacing: 0.01em; }
|
||||||
|
|
||||||
.panel-body { padding: 18px; }
|
.panel-body { padding: 18px; }
|
||||||
|
|
||||||
|
/* Collapsible panels: a folded one shows only its head. */
|
||||||
|
.fold-head { cursor: pointer; user-select: none; }
|
||||||
|
.fold-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fold-toggle svg { width: 14px; height: 14px; color: var(--muted); transition: transform 0.15s; }
|
||||||
|
.fold-toggle[aria-expanded="true"] svg { transform: rotate(90deg); }
|
||||||
|
.panel.folded > .fold-head { border-bottom: 0; }
|
||||||
|
.panel.folded > :not(.fold-head), .panel.folded .fold-open, .panel:not(.folded) .fold-shut { display: none; }
|
||||||
|
|
||||||
/* ---------- Controls ---------- */
|
/* ---------- Controls ---------- */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
|
|
@ -572,6 +590,8 @@ td.num, th.num { text-align: right; white-space: nowrap; }
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rank-form { display: grid; gap: 18px; }
|
||||||
|
|
||||||
.ladder { list-style: none; margin: 0; padding: 8px; display: grid; gap: 2px; }
|
.ladder { list-style: none; margin: 0; padding: 8px; display: grid; gap: 2px; }
|
||||||
|
|
||||||
.ladder button {
|
.ladder button {
|
||||||
|
|
@ -639,6 +659,8 @@ td.num, th.num { text-align: right; white-space: nowrap; }
|
||||||
/* ---------- Settings ---------- */
|
/* ---------- Settings ---------- */
|
||||||
|
|
||||||
.settings { display: grid; gap: 20px; max-width: 900px; }
|
.settings { display: grid; gap: 20px; max-width: 900px; }
|
||||||
|
.rcon-form { display: flex; gap: 8px; }
|
||||||
|
.rcon-form .input { flex: 1; min-width: 0; font-family: ui-monospace, "Cascadia Mono", Menlo, monospace; }
|
||||||
|
|
||||||
.setting {
|
.setting {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ func (s *Server) Handler() http.Handler {
|
||||||
admin("POST /api/admin/map", s.changeMap)
|
admin("POST /api/admin/map", s.changeMap)
|
||||||
admin("POST /api/admin/restart", s.restartRound)
|
admin("POST /api/admin/restart", s.restartRound)
|
||||||
admin("POST /api/admin/reload-admins", s.reloadAdmins)
|
admin("POST /api/admin/reload-admins", s.reloadAdmins)
|
||||||
|
admin("POST /api/admin/rcon", s.rcon)
|
||||||
admin("POST /api/admin/groups", s.createGroup)
|
admin("POST /api/admin/groups", s.createGroup)
|
||||||
admin("PUT /api/admin/groups/{id}", s.updateGroup)
|
admin("PUT /api/admin/groups/{id}", s.updateGroup)
|
||||||
admin("DELETE /api/admin/groups/{id}", s.deleteGroup)
|
admin("DELETE /api/admin/groups/{id}", s.deleteGroup)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue