feat(routing): add user admin panel support
Adds the user administration route, associated server handlers, and frontend assets for managing user accounts.
This commit is contained in:
309
static/css/users.css
Normal file
309
static/css/users.css
Normal file
@@ -0,0 +1,309 @@
|
||||
.users-page-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.users-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(300px, .9fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.users-hero h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.users-hero p {
|
||||
margin: 0;
|
||||
color: #333333;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.users-hero-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.users-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.users-stat-card {
|
||||
padding: 8px;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
}
|
||||
|
||||
.users-stat-card p {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.users-stat-card strong {
|
||||
font-size: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.users-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||
.users-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||
.users-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||
.users-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||
|
||||
.users-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.users-panel {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.users-panel-header {
|
||||
flex: 0 0 auto;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #dfdfdf;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
}
|
||||
|
||||
.users-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.users-panel-title span {
|
||||
font-weight: normal;
|
||||
color: #444444;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.users-panel-tools {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.users-panel-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||
}
|
||||
|
||||
.users-list-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.users-form-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.users-row-two {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.users-field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.users-input,
|
||||
.users-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.users-check {
|
||||
min-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.users-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.users-action-button,
|
||||
.users-tool-button,
|
||||
.users-page-button {
|
||||
min-width: 70px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.users-toolbar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.users-table-wrap {
|
||||
min-height: 420px;
|
||||
height: 420px;
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
border-top: 2px solid #606060;
|
||||
border-left: 2px solid #606060;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #e1e1e1;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
text-align: left;
|
||||
background: #dfdfdf;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
}
|
||||
|
||||
.users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||
.users-table tbody tr:hover { background: #d8e5f8; }
|
||||
|
||||
.users-col-check { width: 30px; }
|
||||
.users-col-actions { width: 136px; }
|
||||
|
||||
.users-username {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.users-username strong {
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.users-muted {
|
||||
color: #555555;
|
||||
font-size: 11px;
|
||||
line-height: 11px;
|
||||
}
|
||||
|
||||
.users-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 0 6px;
|
||||
color: #222222;
|
||||
background: #f1f1f1;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #b0b0b0;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.users-pill.active { background: #def2e0; }
|
||||
.users-pill.pending { background: #fff1c9; }
|
||||
.users-pill.disabled { background: #ffdcdc; }
|
||||
|
||||
.users-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.users-row-button {
|
||||
min-width: 60px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.users-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.users-main-grid,
|
||||
.users-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
304
static/js/admin/users.js
Normal file
304
static/js/admin/users.js
Normal file
@@ -0,0 +1,304 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const toastTarget = document.getElementById("toast");
|
||||
const body = document.getElementById("users-body");
|
||||
const search = document.getElementById("users-search");
|
||||
const status = document.getElementById("users-status");
|
||||
const role = document.getElementById("users-role-filter");
|
||||
const sort = document.getElementById("users-sort");
|
||||
const size = document.getElementById("users-size");
|
||||
const masterCheck = document.getElementById("users-master-check");
|
||||
const pageInfo = document.getElementById("users-page-info");
|
||||
const visiblePill = document.getElementById("visible-pill");
|
||||
const selectedPill = document.getElementById("users-selected-pill");
|
||||
const prevBtn = document.getElementById("users-prev");
|
||||
const nextBtn = document.getElementById("users-next");
|
||||
const selectVisible = document.getElementById("select-visible");
|
||||
const form = document.getElementById("users-form");
|
||||
const modeInput = document.getElementById("users-mode");
|
||||
const usernameInput = document.getElementById("users-username");
|
||||
const emailInput = document.getElementById("users-email");
|
||||
const roleInput = document.getElementById("users-role");
|
||||
const planInput = document.getElementById("users-plan");
|
||||
const statusLeft = document.getElementById("users-status-left");
|
||||
|
||||
if (!body || !search || !status || !role || !sort || !size) return;
|
||||
|
||||
const users = [
|
||||
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" },
|
||||
{ id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" },
|
||||
{ id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" },
|
||||
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" },
|
||||
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" },
|
||||
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
|
||||
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
|
||||
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
|
||||
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
|
||||
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
|
||||
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
|
||||
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
|
||||
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
|
||||
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
|
||||
];
|
||||
|
||||
const state = { page: 1, selected: new Set() };
|
||||
|
||||
function toast(message, type = "info") {
|
||||
if (window.WarpBoxUI) {
|
||||
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
|
||||
return;
|
||||
}
|
||||
if (!toastTarget) return;
|
||||
toastTarget.textContent = message;
|
||||
toastTarget.classList.add("is-visible");
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
const statusFilter = status.value;
|
||||
const roleFilter = role.value;
|
||||
const sortBy = sort.value;
|
||||
const rows = users.filter((user) => {
|
||||
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
|
||||
const matchesStatus = statusFilter === "all" || user.status === statusFilter;
|
||||
const matchesRole = roleFilter === "all" || user.role === roleFilter;
|
||||
return matchesQuery && matchesStatus && matchesRole;
|
||||
});
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (sortBy === "createdDesc") return b.created.localeCompare(a.created);
|
||||
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen);
|
||||
if (sortBy === "boxesDesc") return b.boxes - a.boxes;
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function paged(rows) {
|
||||
const perPage = Number(size.value || 12);
|
||||
const pages = Math.max(1, Math.ceil(rows.length / perPage));
|
||||
if (state.page > pages) state.page = pages;
|
||||
if (state.page < 1) state.page = 1;
|
||||
const start = (state.page - 1) * perPage;
|
||||
return { rows: rows.slice(start, start + perPage), pages, start };
|
||||
}
|
||||
|
||||
function statusPill(value) {
|
||||
return `<span class="users-pill ${value}">${value}</span>`;
|
||||
}
|
||||
|
||||
function renderRow(user) {
|
||||
const checked = state.selected.has(user.id) ? " checked" : "";
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td><input type="checkbox" class="row-check"${checked}></td>
|
||||
<td><div class="users-username"><strong>${user.username}</strong><span class="users-muted">${user.id}</span></div></td>
|
||||
<td title="${user.email}">${user.email}</td>
|
||||
<td>${statusPill(user.status)}</td>
|
||||
<td>${user.role}</td>
|
||||
<td>${user.plan}</td>
|
||||
<td>${user.boxes}</td>
|
||||
<td>${user.lastSeen}</td>
|
||||
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="open">Open</button></div></td>
|
||||
`;
|
||||
|
||||
row.querySelector(".row-check")?.addEventListener("change", (event) => {
|
||||
if (event.target.checked) state.selected.add(user.id);
|
||||
else state.selected.delete(user.id);
|
||||
syncSelected();
|
||||
syncMasterCheck();
|
||||
});
|
||||
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
|
||||
toast(`Mock user preview: ${user.username}`);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function syncSelected() {
|
||||
selectedPill.textContent = `${state.selected.size} selected`;
|
||||
}
|
||||
|
||||
function syncMasterCheck() {
|
||||
const checks = Array.from(body.querySelectorAll(".row-check"));
|
||||
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
document.getElementById("stat-total").textContent = String(users.length);
|
||||
document.getElementById("stat-active").textContent = String(users.filter((u) => u.status === "active").length);
|
||||
document.getElementById("stat-pending").textContent = String(users.filter((u) => u.status === "pending").length);
|
||||
document.getElementById("stat-disabled").textContent = String(users.filter((u) => u.status === "disabled").length);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = filtered();
|
||||
const page = paged(rows);
|
||||
body.innerHTML = "";
|
||||
page.rows.forEach((user) => body.appendChild(renderRow(user)));
|
||||
|
||||
visiblePill.textContent = `${rows.length} visible`;
|
||||
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
|
||||
prevBtn.disabled = state.page <= 1;
|
||||
nextBtn.disabled = state.page >= page.pages;
|
||||
statusLeft.textContent = `Ready. ${rows.length} user rows in current filter.`;
|
||||
syncSelected();
|
||||
syncMasterCheck();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search.value = "";
|
||||
status.value = "all";
|
||||
role.value = "all";
|
||||
sort.value = "username";
|
||||
state.page = 1;
|
||||
render();
|
||||
}
|
||||
|
||||
function applyBulk(nextStatus) {
|
||||
const selected = users.filter((user) => state.selected.has(user.id));
|
||||
if (!selected.length) {
|
||||
toast("Select one or more users first", "warning");
|
||||
return;
|
||||
}
|
||||
selected.forEach((user) => { user.status = nextStatus; });
|
||||
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
|
||||
renderStats();
|
||||
render();
|
||||
}
|
||||
|
||||
function runCommand(command) {
|
||||
switch (command) {
|
||||
case "invite":
|
||||
modeInput.value = "invite";
|
||||
toast("Invite mode selected");
|
||||
break;
|
||||
case "create":
|
||||
modeInput.value = "create";
|
||||
toast("Create mode selected");
|
||||
break;
|
||||
case "export":
|
||||
toast("Mock CSV export complete");
|
||||
break;
|
||||
case "bulk-disable":
|
||||
applyBulk("disabled");
|
||||
break;
|
||||
case "bulk-enable":
|
||||
applyBulk("active");
|
||||
break;
|
||||
case "bulk-revoke":
|
||||
toast("Mock session revocation queued");
|
||||
break;
|
||||
case "refresh":
|
||||
toast("Users list refreshed");
|
||||
render();
|
||||
break;
|
||||
case "pending-only":
|
||||
status.value = "pending";
|
||||
state.page = 1;
|
||||
render();
|
||||
break;
|
||||
case "clear-filters":
|
||||
clearFilters();
|
||||
break;
|
||||
case "policy-help":
|
||||
toast("Policy editor will be added in user details later.");
|
||||
break;
|
||||
case "mock-note":
|
||||
toast("Mock-only page: no backend writes yet.");
|
||||
break;
|
||||
default:
|
||||
toast(`Mock action: ${command}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[search, status, role, sort, size].forEach((el) => {
|
||||
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => {
|
||||
state.page = 1;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
prevBtn.addEventListener("click", () => {
|
||||
state.page -= 1;
|
||||
render();
|
||||
});
|
||||
|
||||
nextBtn.addEventListener("click", () => {
|
||||
state.page += 1;
|
||||
render();
|
||||
});
|
||||
|
||||
masterCheck.addEventListener("change", () => {
|
||||
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||
const checkbox = row.querySelector(".row-check");
|
||||
if (!checkbox) return;
|
||||
checkbox.checked = masterCheck.checked;
|
||||
const userID = row.querySelector(".users-muted")?.textContent || "";
|
||||
if (masterCheck.checked) state.selected.add(userID);
|
||||
else state.selected.delete(userID);
|
||||
});
|
||||
syncSelected();
|
||||
});
|
||||
|
||||
selectVisible.addEventListener("click", () => {
|
||||
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||
const checkbox = row.querySelector(".row-check");
|
||||
const userID = row.querySelector(".users-muted")?.textContent || "";
|
||||
if (!checkbox) return;
|
||||
checkbox.checked = true;
|
||||
state.selected.add(userID);
|
||||
});
|
||||
syncSelected();
|
||||
syncMasterCheck();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const username = usernameInput.value.trim();
|
||||
const email = emailInput.value.trim();
|
||||
const mode = modeInput.value;
|
||||
if (!username || !email) {
|
||||
toast("Username and email are required", "warning");
|
||||
return;
|
||||
}
|
||||
users.unshift({
|
||||
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
|
||||
username,
|
||||
email,
|
||||
status: mode === "invite" ? "pending" : "active",
|
||||
role: roleInput.value,
|
||||
plan: planInput.value,
|
||||
boxes: 0,
|
||||
created: new Date().toISOString().slice(0, 10),
|
||||
lastSeen: "never"
|
||||
});
|
||||
form.reset();
|
||||
modeInput.value = "invite";
|
||||
renderStats();
|
||||
render();
|
||||
toast(mode === "invite" ? "Mock invite created" : "Mock user created");
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
menuController.close();
|
||||
runCommand(button.dataset.command);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
runCommand("refresh");
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
|
||||
event.preventDefault();
|
||||
runCommand("invite");
|
||||
}
|
||||
});
|
||||
|
||||
renderStats();
|
||||
render();
|
||||
})();
|
||||
Reference in New Issue
Block a user