Files
Vanadium-2023-2025-Leak-Server/RecNet/Frontend/Director/Director.js
T
skidfire fe3468c65d yikes,
2026-09-06 19:59:27 -04:00

2698 lines
122 KiB
JavaScript

const BASE = "/vannet/api/director";
const SESSION_DURATION_MS = 45 * 60 * 1000;
const REDIRECT_URL = "/vannet/Director";
let currentPlayer = null;
let currentRoom = null;
let allSkins = [];
let sessionToken = null;
let sessionExpiry = 0;
let timerInterval = null;
let sessionWs = null;
function safeParse(text) {
const fixed = text.replace(/(?<!["'])(-?[0-9]{15,})(?=[ \t]*[,\]\}\r\n])/g, '"$1"');
return JSON.parse(fixed);
}
(function checkAuth() {
sessionToken = sessionStorage.getItem("director_token");
const expiry = parseInt(sessionStorage.getItem("director_token_expiry") || "0", 10);
if (!sessionToken || Date.now() > expiry) {
sessionStorage.removeItem("director_token");
sessionStorage.removeItem("director_token_expiry");
window.location.href = REDIRECT_URL;
return;
}
sessionExpiry = expiry;
const isMobile = /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768;
const app = document.getElementById("app");
if (isMobile) app.classList.add("mobile");
app.style.display = "flex";
initApp();
startSessionTimer();
openSessionWs();
})();
function startSessionTimer() {
if (timerInterval) clearInterval(timerInterval);
timerInterval = setInterval(() => {
const remaining = sessionExpiry - Date.now();
if (remaining <= 0) {
clearInterval(timerInterval);
forceLogout("Your session has expired. Please re-authorize.");
return;
}
const mins = Math.floor(remaining / 60000);
const secs = Math.floor((remaining % 60000) / 1000);
const display = `${mins}:${secs.toString().padStart(2, "0")}`;
const v1 = document.getElementById("timer-val");
const v2 = document.getElementById("session-big-timer");
if (v1) v1.textContent = display;
if (v2) v2.textContent = display;
}, 1000);
}
function extendSession() {
sessionExpiry = Date.now() + SESSION_DURATION_MS;
sessionStorage.setItem("director_token_expiry", sessionExpiry);
}
function forceLogout(msg) {
clearInterval(timerInterval);
if (sessionWs) { try { sessionWs.close(); } catch (_) { } }
sessionStorage.removeItem("director_token");
sessionStorage.removeItem("director_token_expiry");
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:999999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;color:#e8e8f0;font-family:system-ui;text-align:center;padding:24px";
overlay.innerHTML = `<div style="font-size:36px">⬡</div><div style="font-size:16px;font-weight:600">${msg || "Session ended."}</div><div style="color:#7a7a9a;font-size:13px">Redirecting to authorization…</div>`;
document.body.appendChild(overlay);
setTimeout(() => { window.location.href = REDIRECT_URL; }, 2000);
}
function openSessionWs() {
if (!sessionToken) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${proto}//${location.host}/vannet/ws/director-session?token=${encodeURIComponent(sessionToken)}`;
try {
sessionWs = new WebSocket(wsUrl);
sessionWs.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === "session_expired" || data.type === "force_logout") {
forceLogout(data.message || "Session was terminated by the server.");
}
} catch (_) { }
};
sessionWs.onclose = () => {
if (Date.now() < sessionExpiry) {
setTimeout(openSessionWs, 3000);
}
};
} catch (_) { }
}
function gv(obj, ...keys) {
for (const k of keys) {
if (obj[k] !== undefined && obj[k] !== null) return obj[k];
const lower = k.charAt(0).toLowerCase() + k.slice(1);
if (obj[lower] !== undefined && obj[lower] !== null) return obj[lower];
const upper = k.charAt(0).toUpperCase() + k.slice(1);
if (obj[upper] !== undefined && obj[upper] !== null) return obj[upper];
}
return null;
}
function initApp() {
document.querySelectorAll(".nav-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".nav-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
btn.classList.add("active");
document.getElementById("panel-" + btn.dataset.panel).classList.add("active");
extendSession();
});
});
setupPlayerPanel();
setupRoomPanel();
setupImagesPanel();
setupDatabasePanel();
setupModerationPanel();
setupModBoardPanel();
setupAntiCheatPanel();
setupServerPanel();
setupGitPanel();
setupSessionPanel();
setupRoomImport();
loadSkins();
renderSessionInfo();
initUploadPreviews();
}
function setupGitPanel() {
const diffBtn = document.getElementById("git-diff");
const statusBtn = document.getElementById("git-status");
const pushBtn = document.getElementById("git-push");
const log = document.getElementById("git-log");
if (!diffBtn || !statusBtn || !pushBtn || !log) return;
diffBtn.addEventListener("click", async () => {
diffBtn.disabled = true;
log.value = "Running git diff HEAD...";
const res = await api("/git/diff");
if (!res.ok) {
const err = typeof res.data === "string"
? res.data
: res.data?.error || "Failed to run git diff.";
log.value = err;
toast("Git diff failed", "err");
diffBtn.disabled = false;
return;
}
const exitCode = (typeof res.data?.exitCode === "number") ? res.data.exitCode : null;
const output = res.data?.output;
const body = (typeof output === "string" && output.length > 0)
? output
: "No diff output.";
log.value = (exitCode !== null ? `Exit code: ${exitCode}\n\n` : "") + body;
diffBtn.disabled = false;
});
statusBtn.addEventListener("click", async () => {
statusBtn.disabled = true;
log.value = "Running git status -sb...";
const res = await api("/git/status");
if (!res.ok) {
const err = typeof res.data === "string"
? res.data
: res.data?.error || "Failed to run git status.";
log.value = err;
toast("Git status failed", "err");
statusBtn.disabled = false;
return;
}
const exitCode = (typeof res.data?.exitCode === "number") ? res.data.exitCode : null;
const output = res.data?.output;
const body = (typeof output === "string" && output.length > 0)
? output
: "No status output.";
log.value = (exitCode !== null ? `Exit code: ${exitCode}\n\n` : "") + body;
statusBtn.disabled = false;
});
pushBtn.addEventListener("click", async () => {
pushBtn.disabled = true;
log.value = "Running git commit and push...";
const res = await api("/git/push");
if (!res.ok) {
const err = typeof res.data === "string"
? res.data
: res.data?.error || "Failed to run git push.";
log.value = err;
toast("Git push failed", "err");
pushBtn.disabled = false;
return;
}
const exitCode = (typeof res.data?.exitCode === "number") ? res.data.exitCode : null;
const output = res.data?.output;
const body = (typeof output === "string" && output.length > 0)
? output
: "No push output.";
log.value = (exitCode !== null ? `Exit code: ${exitCode}\n\n` : "") + body;
pushBtn.disabled = false;
});
}
async function api(path, method = "GET", body = null) {
extendSession();
const opts = {
method,
headers: {
"Content-Type": "application/json",
"X-Director-Token": sessionToken || ""
}
};
if (body) opts.body = JSON.stringify(body);
try {
const res = await fetch(BASE + path, opts);
if (res.status === 401 || res.status === 403) {
forceLogout("Your session is no longer valid.");
return { ok: false, status: res.status, data: null };
}
const text = await res.text();
try { return { ok: res.ok, status: res.status, data: safeParse(text) }; }
catch { return { ok: res.ok, status: res.status, data: text }; }
} catch (e) {
return { ok: false, status: 0, data: e.message };
}
}
async function apiUpload(path, formData) {
extendSession();
try {
const res = await fetch(BASE + path, {
method: "POST",
headers: { "X-Director-Token": sessionToken || "" },
body: formData
});
if (res.status === 401 || res.status === 403) {
forceLogout("Your session is no longer valid.");
return { ok: false, status: res.status, data: null };
}
const text = await res.text();
try { return { ok: res.ok, status: res.status, data: safeParse(text) }; }
catch { return { ok: res.ok, status: res.status, data: text }; }
} catch (e) {
return { ok: false, status: 0, data: e.message };
}
}
function toast(msg, type = "info") {
const el = document.createElement("div");
el.className = "toast" + (type === "err" ? " toast-err" : type === "ok" ? " toast-ok" : "");
el.textContent = msg;
document.getElementById("toast-container").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 300); }, 3200);
}
function showConfirm(title, body, cb) {
document.getElementById("modal-title").textContent = title;
document.getElementById("modal-body").textContent = body;
document.getElementById("modal-overlay").style.display = "flex";
const yes = document.getElementById("modal-confirm");
const no = document.getElementById("modal-cancel");
const cleanup = () => {
document.getElementById("modal-overlay").style.display = "none";
yes.replaceWith(yes.cloneNode(true));
no.replaceWith(no.cloneNode(true));
};
document.getElementById("modal-confirm").addEventListener("click", () => { cleanup(); cb(); });
document.getElementById("modal-cancel").addEventListener("click", cleanup);
}
function infoGrid(obj, containerId) {
const el = document.getElementById(containerId);
el.innerHTML = "";
for (const [k, v] of Object.entries(obj)) {
const item = document.createElement("div");
item.className = "info-item";
item.innerHTML = `<div class="info-label">${k}</div><div class="info-val">${v ?? "-"}</div>`;
el.appendChild(item);
}
}
function pfpUrl(img) { return img ? `/imageserver/${img}` : "/imageserver/DefaultPFP.png"; }
function roomImgUrl(img) { return img ? `/imageserver/${img}` : "/imageserver/DefaultRoomImage.png"; }
function setupDetailTabs(containerSelector) {
document.querySelectorAll(containerSelector + " .dtab").forEach(tab => {
tab.addEventListener("click", () => {
const parent = tab.closest(".detail-pane");
parent.querySelectorAll(".dtab").forEach(t => t.classList.remove("active"));
parent.querySelectorAll(".dtab-content").forEach(c => c.classList.remove("active"));
tab.classList.add("active");
document.getElementById(tab.dataset.tab).classList.add("active");
});
});
}
function renderSessionInfo() {
const grid = document.getElementById("session-info-grid");
if (!grid) return;
infoGrid({
"Token": sessionToken ? sessionToken.substring(0, 20) + "…" : "-",
"Expires": new Date(sessionExpiry).toLocaleTimeString(),
"Duration": "45 minutes"
}, "session-info-grid");
}
function wireUploadPreview(inputId, previewId, isCircle) {
const input = document.getElementById(inputId);
const container = document.getElementById(previewId);
if (!input || !container) return;
input.addEventListener("change", () => {
const file = input.files[0];
if (!file) { container.style.display = "none"; return; }
const url = URL.createObjectURL(file);
container.style.display = "flex";
const img = container.querySelector("img");
const strong = container.querySelector("strong");
const span = container.querySelector(".upload-preview-meta span");
img.src = url;
img.style.borderRadius = isCircle ? "50%" : "var(--radius-sm)";
strong.textContent = file.name;
span.textContent = (file.size / 1024).toFixed(1) + " KB · " + file.type;
});
}
function initUploadPreviews() {
const pfpInput = document.getElementById("pa-pfp-upload");
if (pfpInput && !document.getElementById("pfp-upload-preview")) {
pfpInput.insertAdjacentHTML("afterend", `
<div class="upload-preview" id="pfp-upload-preview" style="display:none">
<img src="" alt="preview">
<div class="upload-preview-meta">
<strong></strong>
<span></span>
<div style="color:var(--accent2);font-size:11px;margin-top:2px">Ready to upload</div>
</div>
</div>`);
wireUploadPreview("pa-pfp-upload", "pfp-upload-preview", true);
}
const roomInput = document.getElementById("ra-image-upload");
if (roomInput && !document.getElementById("room-img-upload-preview")) {
roomInput.insertAdjacentHTML("afterend", `
<div class="upload-preview" id="room-img-upload-preview" style="display:none">
<img src="" alt="preview">
<div class="upload-preview-meta">
<strong></strong>
<span></span>
<div style="color:var(--accent2);font-size:11px;margin-top:2px">Ready to upload</div>
</div>
</div>`);
wireUploadPreview("ra-image-upload", "room-img-upload-preview", false);
}
}
function setupSessionPanel() {
document.getElementById("session-extend-btn").addEventListener("click", () => {
extendSession();
renderSessionInfo();
toast("Session extended by 45 minutes", "ok");
});
document.getElementById("session-logout-btn").addEventListener("click", () => {
showConfirm("Sign Out", "End your session and return to the authorization page?", () => {
forceLogout("Signed out successfully.");
});
});
}
function setupPlayerPanel() {
document.getElementById("player-search-btn").addEventListener("click", playerSearch);
document.getElementById("player-search-input").addEventListener("keydown", e => { if (e.key === "Enter") playerSearch(); });
document.getElementById("player-list-all-btn").addEventListener("click", playerListAll);
document.getElementById("player-list-devs-btn").addEventListener("click", playerListDevelopers);
document.getElementById("player-list-mods-btn").addEventListener("click", playerListModerators);
document.getElementById("pd-close").addEventListener("click", () => {
document.getElementById("player-detail").style.display = "none";
currentPlayer = null;
});
document.getElementById("player-create-toggle-btn").addEventListener("click", () => {
const section = document.getElementById("player-create-section");
section.style.display = section.style.display === "none" ? "block" : "none";
});
document.getElementById("pc-create-btn").addEventListener("click", createPlayer);
setupDetailTabs("#player-detail");
setupPlayerActions();
}
async function createPlayer() {
const idInput = document.getElementById("pc-player-id");
const playerId = parseInt(idInput.value, 10);
if (!playerId || playerId <= 0) { toast("Enter a valid Player ID", "err"); return; }
const username = document.getElementById("pc-username").value.trim();
const isJunior = document.getElementById("pc-junior").checked;
const res = await api("/players/create", "POST", {
PlayerId: playerId,
Username: username || null,
IsJunior: isJunior
});
if (!res.ok) {
toast((res.data && res.data.error) || "Failed to create player", "err");
return;
}
toast(`Created player ${res.data.playerId} (${res.data.username})`, "ok");
idInput.value = "";
document.getElementById("pc-username").value = "";
document.getElementById("pc-junior").checked = false;
document.getElementById("player-create-section").style.display = "none";
loadPlayerDetail(res.data.playerId);
}
async function playerSearch() {
const q = document.getElementById("player-search-input").value.trim();
if (!q) return;
extendSession();
try {
const res = await fetch(`/acc/account/search?name=${encodeURIComponent(q)}`, {
headers: { "X-Director-Token": sessionToken || "" }
});
if (!res.ok) { toast("Search failed", "err"); return; }
const data = await res.json();
renderPlayerResults(data.Results || data.results || data);
} catch (e) {
toast("Search failed", "err");
}
}
async function playerListAll() {
const res = await api("/players/search?take=100");
if (!res.ok) { toast("Failed to load players", "err"); return; }
renderPlayerResults(res.data.Results || res.data.results || res.data);
}
async function playerListDevelopers() {
const res = await api("/players/list-developers");
if (!res.ok) { toast("Failed to load developers", "err"); return; }
renderPlayerResults(res.data.Results || res.data.results || res.data);
}
async function playerListModerators() {
const res = await api("/players/list-moderators");
if (!res.ok) { toast("Failed to load moderators", "err"); return; }
renderPlayerResults(res.data.Results || res.data.results || res.data);
}
function renderPlayerResults(players) {
const el = document.getElementById("player-results");
el.innerHTML = "";
if (!players || players.length === 0) {
el.innerHTML = '<div class="empty-state">No players found.</div>';
return;
}
const grid = document.createElement("div");
grid.className = "results-grid";
players.forEach(p => {
const id = gv(p, "accountId", "playerId", "PlayerId", "AccountId");
const name = gv(p, "displayName", "username", "DisplayName", "Username") || "Unknown";
const username = gv(p, "username", "Username") || "?";
const img = gv(p, "profileImage", "ProfileImage");
const isOnline = gv(p, "isOnline") || gv(p, "heartbeat")?.isOnline || false;
const level = gv(p, "level", "Level") || 1;
const card = document.createElement("div");
card.className = "grid-card";
card.innerHTML = `
<img class="grid-card-thumb" src="${pfpUrl(img)}" onerror="this.src='/imageserver/DefaultPFP.png'" style="border-radius:0">
<div class="grid-card-badge"><span class="status-dot ${isOnline ? "status-dot-online" : "status-dot-offline"}"></span></div>
<div class="grid-card-body">
<div class="grid-card-name">${name}</div>
<div class="grid-card-sub">@${username} · Lv ${level}</div>
<div class="grid-card-sub">ID: ${id}</div>
</div>`;
card.addEventListener("click", () => loadPlayerDetail(id));
grid.appendChild(card);
});
el.appendChild(grid);
}
async function loadPlayerDetail(id) {
const res = await api(`/players/${id}`);
if (!res.ok) { toast("Failed to load player", "err"); return; }
currentPlayer = res.data;
currentPlayer._id = gv(res.data, "accountId", "playerId", "AccountId", "PlayerId");
renderPlayerDetail(res.data);
document.getElementById("player-detail").style.display = "block";
document.getElementById("player-detail").scrollIntoView({ behavior: "smooth", block: "start" });
}
function renderPlayerDetail(p) {
const id = p._id || gv(p, "accountId", "playerId", "AccountId", "PlayerId");
const name = gv(p, "displayName", "username", "DisplayName", "Username") || "Unknown";
const username = gv(p, "username", "Username") || "?";
const img = gv(p, "profileImage", "ProfileImage");
document.getElementById("pd-pfp").src = pfpUrl(img);
document.getElementById("pd-name").textContent = name;
document.getElementById("pd-username").textContent = "@" + username;
document.getElementById("pd-id").textContent = "ID: " + id;
const hb = gv(p, "heartbeat") || {};
const bd = gv(p, "moderationBlockDetails") || {};
const roles = gv(p, "roles", "playerRoles") || [];
infoGrid({
"Player ID": id,
"Username": gv(p, "username", "Username"),
"Display Name": gv(p, "displayName", "DisplayName"),
"Level": gv(p, "level", "Level"),
"XP": gv(p, "xp", "XP"),
"Created At": fmtDate(gv(p, "createdAt", "CreatedAt")),
"Last Login": fmtDate(gv(p, "lastLoginAt", "LastLoginAt")),
"Is Junior": gv(p, "isJunior", "IsJunior") ? "Yes" : "No",
"Profile Image": img,
"Dorm Room ID": gv(p, "dormRoomId", "DormRoomId"),
"Bio": gv(p, "bio", "Bio") || "-",
"Status": hb.isOnline ? "Online" : "Offline",
"Current Room": hb.roomInstance?.roomId || "None",
"Banned": bd.isBan || bd.IsBan ? "Yes" : "No",
"Discord": discordLabel(p),
"Roles": (Array.isArray(roles) ? roles : []).join(", ") || "None"
}, "pd-info-grid");
const pa_username = document.getElementById("pa-username");
if (pa_username) pa_username.value = gv(p, "username", "Username") || "";
renderPlayerRoles(p);
renderPlayerBanStatus(p);
loadPlayerDetailRooms(id);
loadOwnedSkins(p);
}
function renderPlayerRoles(p) {
const roles = gv(p, "roles", "playerRoles", "PlayerRoles") || [];
const grid = document.getElementById("pd-roles-grid");
grid.innerHTML = "";
if (!roles.length) {
grid.innerHTML = '<span class="muted">No roles assigned.</span>';
} else {
roles.forEach(r => {
const chip = document.createElement("span");
chip.className = "role-chip";
chip.textContent = r;
grid.appendChild(chip);
});
}
document.querySelectorAll(".btn-role").forEach(btn => {
btn.classList.toggle("has-role", roles.includes(btn.dataset.role));
});
}
function discordLabel(p) {
const linked = gv(p, "discordLinked", "DiscordLinked");
const uname = gv(p, "discordUsername", "DiscordUsername");
const uid = gv(p, "discordUserId", "DiscordUserId");
if (!linked && !uid) return "Not linked";
const who = uname ? `@${uname}` : "";
const idPart = uid ? `(${uid})` : "";
return `${who} ${idPart}`.trim() || "Linked";
}
function renderPlayerBanStatus(p) {
const bd = gv(p, "moderationBlockDetails", "ModerationBlockDetails") || {};
const el = document.getElementById("pa-ban-status");
const isBanned = bd.isBan || bd.IsBan;
if (!isBanned) {
el.innerHTML = '<span class="badge badge-offline">Not Banned</span>';
} else {
el.innerHTML = `<span class="badge badge-banned">Banned</span>
<div class="muted" style="margin-top:6px">Reason: ${bd.message || bd.Message || "-"}</div>
<div class="muted">Duration: ${bd.duration || bd.Duration || "-"} days</div>`;
}
const toxEl = document.getElementById("pa-toxmod-status");
if (toxEl) {
const tm = gv(p, "toxMod", "ToxMod") || {};
const strikes = tm.strikes ?? tm.Strikes ?? 0;
const banned = bd.isVoiceModAutoban || bd.IsVoiceModAutoban;
const category = tm.activeBanCategory || tm.ActiveBanCategory;
const expiresAt = tm.activeBanExpiresUnixTime ?? tm.ActiveBanExpiresUnixTime ?? 0;
let html = banned
? '<span class="badge badge-banned">Voice Banned</span>'
: '<span class="badge badge-offline">Voice OK</span>';
if (banned) {
if (category) html += `<div class="muted" style="margin-top:6px">Offense: ${category}</div>`;
html += `<div class="muted">Expires: ${expiresAt ? fmtToxModRemaining(expiresAt) : "never (manual lift required)"}</div>`;
}
html += `<div class="muted" style="margin-top:${banned ? 0 : 6}px">ToxMod strikes: ${strikes}</div>`;
toxEl.innerHTML = html;
}
const juniorEl = document.getElementById("pa-junior-status");
if (juniorEl) {
const isJr = gv(p, "isJunior", "IsJunior");
let html = isJr
? '<span class="badge badge-banned">Comms Restricted (Junior)</span>'
: '<span class="badge badge-offline">Not Junior</span>';
if (isJr) {
const js = gv(p, "juniorStatus", "JuniorStatus") || {};
const reason = js.reason || js.Reason;
const by = js.setBy || js.SetBy;
const at = js.setAt || js.SetAt;
if (reason) html += `<div class="muted" style="margin-top:6px">Reason: ${reason}</div>`;
if (by || at) html += `<div class="muted">By: ${by || "?"}${at ? " · " + fmtDate(at) : ""}</div>`;
}
juniorEl.innerHTML = html;
}
const nameLockEl = document.getElementById("pa-namelock-status");
if (nameLockEl) {
const locked = gv(p, "nameLocked", "NameLocked");
nameLockEl.innerHTML = locked
? '<span class="badge badge-banned">Name Locked</span>'
: '<span class="badge badge-offline">Name Unlocked</span>';
}
const rrplusEl = document.getElementById("pa-rrplus-status");
if (rrplusEl) {
const has = gv(p, "recRoomPlus", "RecRoomPlus");
rrplusEl.innerHTML = has
? '<span class="badge badge-online">Rec Room Plus</span>'
: '<span class="badge badge-offline">No RR+</span>';
}
}
function fmtToxModRemaining(expiresUnixTime) {
const secs = expiresUnixTime - Math.floor(Date.now() / 1000);
if (secs <= 0) return "expired (lifts on next check)";
const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600), m = Math.floor((secs % 3600) / 60);
const parts = [];
if (d) parts.push(d + "d");
if (h) parts.push(h + "h");
parts.push(m + "m");
return `${new Date(expiresUnixTime * 1000).toLocaleString()} (in ${parts.join(" ")})`;
}
async function loadPlayerDetailRooms(playerId) {
const res = await api(`/players/${playerId}/rooms`);
const el = document.getElementById("pd-rooms-list");
el.innerHTML = "";
const rooms = res.ok ? (res.data.rooms || res.data.Rooms || res.data || []) : [];
if (!rooms.length) {
el.innerHTML = '<div class="empty-state">No rooms found.</div>';
return;
}
const grid = document.createElement("div");
grid.className = "pd-rooms-grid";
rooms.forEach(r => {
const rid = gv(r, "roomId", "RoomId");
const rname = gv(r, "name", "Name", "displayName", "DisplayName") || "Unknown";
const rimg = gv(r, "imageName", "ImageName");
const visits = gv(r, "stats", "Stats")?.visitCount ?? gv(r, "stats", "Stats")?.VisitCount ?? 0;
const card = document.createElement("div");
card.className = "grid-card";
card.innerHTML = `
<img class="grid-card-thumb" src="${roomImgUrl(rimg)}" style="border-radius:0" onerror="this.src='/imageserver/DefaultRoomImage.png'">
<div class="grid-card-body">
<div class="grid-card-name">${rname}</div>
<div class="grid-card-sub">ID: ${rid} · ${visits} visits</div>
</div>`;
card.addEventListener("click", () => { loadRoomDetail(rid); switchPanel("rooms"); });
grid.appendChild(card);
});
el.appendChild(grid);
}
function setupPlayerActions() {
const pid = () => currentPlayer?._id;
document.getElementById("pa-rename-btn").addEventListener("click", async () => {
if (!pid()) return;
const name = document.getElementById("pa-username").value.trim();
if (!name) return;
const res = await api(`/players/${pid()}/rename`, "POST", { username: name });
if (res.ok) { toast("Renamed to " + name, "ok"); await loadPlayerDetail(pid()); }
else toast("Rename failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-clearname-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/clear-name`, "POST", {});
if (res.ok) { toast("Name cleared: " + (res.data?.newName || "?"), "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-namelock-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/name-lock`, "POST", { locked: true });
if (res.ok) { toast("Name locked", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-nameunlock-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/name-lock`, "POST", { locked: false });
if (res.ok) { toast("Name unlocked", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-bio-btn").addEventListener("click", async () => {
if (!pid()) return;
const bio = document.getElementById("pa-bio").value;
const res = await api(`/players/${pid()}/set-bio`, "POST", { bio });
if (res.ok) { toast("Bio updated", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-password-btn").addEventListener("click", async () => {
if (!pid()) return;
const password = document.getElementById("pa-password").value;
const res = await api(`/players/${pid()}/set-password`, "POST", { password });
if (res.ok) { toast("Password updated", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-pfp-set-btn").addEventListener("click", async () => {
if (!pid()) return;
const img = document.getElementById("pa-pfp-name").value.trim();
if (!img) return;
const res = await api(`/players/${pid()}/set-pfp`, "POST", { imageName: img });
if (res.ok) { toast("PFP updated", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-pfp-upload-btn").addEventListener("click", async () => {
if (!pid()) return;
const fileInput = document.getElementById("pa-pfp-upload");
if (!fileInput.files[0]) { toast("Select a file first", "err"); return; }
const fd = new FormData();
fd.append("file", fileInput.files[0]);
const res = await apiUpload(`/players/${pid()}/upload-pfp`, fd);
if (res.ok) {
toast("PFP uploaded", "ok");
fileInput.value = "";
const preview = document.getElementById("pfp-upload-preview");
if (preview) preview.style.display = "none";
await loadPlayerDetail(pid());
} else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-pfp-reset-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Reset PFP", "Reset this player's profile picture to default?", async () => {
const res = await api(`/players/${pid()}/reset-pfp`, "POST", {});
if (res.ok) { toast("PFP reset", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed", "err");
});
});
document.getElementById("pa-level-btn").addEventListener("click", async () => {
if (!pid()) return;
const level = parseInt(document.getElementById("pa-level").value);
const xpVal = document.getElementById("pa-xp").value;
const xp = xpVal ? parseInt(xpVal) : null;
if (!level) return;
const res = await api(`/players/${pid()}/set-level`, "POST", { level, xp });
if (res.ok) { toast("Level set to " + level, "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-platformid-btn").addEventListener("click", async () => {
if (!pid()) return;
const platformId = document.getElementById("pa-platformid").value.trim();
if (!platformId) return;
const res = await api(`/players/${pid()}/set-platform-id`, "POST", { platformId });
if (res.ok) { toast("Platform ID updated", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-rrplus-add-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/rrplus`, "POST", { enabled: true });
if (res.ok) { toast("Rec Room Plus granted", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-rrplus-remove-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/rrplus`, "POST", { enabled: false });
if (res.ok) { toast("Rec Room Plus removed", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-junior-set-btn").addEventListener("click", async () => {
if (!pid()) return;
const reason = document.getElementById("pa-junior-reason").value.trim() || null;
const res = await api(`/players/${pid()}/set-junior`, "POST", { isJunior: true, reason });
if (res.ok) {
toast("Set as Junior - player notified", "ok");
document.getElementById("pa-junior-reason").value = "";
await loadPlayerDetail(pid());
}
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-junior-unset-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/set-junior`, "POST", { isJunior: false });
if (res.ok) { toast("Junior removed", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-toxmod-ban-btn").addEventListener("click", async () => {
if (!pid()) return;
const category = document.getElementById("pa-toxmod-category").value;
const durVal = document.getElementById("pa-toxmod-duration").value;
const note = document.getElementById("pa-toxmod-note").value.trim();
const loud = document.getElementById("pa-toxmod-mode").value === "loud";
const res = await api(`/players/${pid()}/toxmod/ban`, "POST", {
category,
note: note || null,
durationSeconds: durVal ? parseInt(durVal) : null,
loud
});
if (res.ok) { toast(loud ? "ToxMod voice ban issued (loud)" : "ToxMod voice ban issued (quiet)", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-toxmod-unban-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/toxmod/unban`, "POST", { resetStrikes: false });
if (res.ok) { toast("Voice ban lifted", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-toxmod-reset-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/toxmod/unban`, "POST", { resetStrikes: true });
if (res.ok) { toast("Voice ban lifted and strikes reset", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-hb-clear-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Clear Heartbeat", "Clear heartbeat for this player?", async () => {
const res = await api(`/players/${pid()}/heartbeat-clear`, "POST", {});
if (res.ok) { toast("Heartbeat cleared", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed", "err");
});
});
document.getElementById("pa-hb-view-btn").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/heartbeat`);
if (res.ok) toast(JSON.stringify(res.data), "info");
else toast("Failed", "err");
});
document.getElementById("pa-bring-btn").addEventListener("click", async () => {
if (!pid()) return;
const username = document.getElementById("pa-bring-username").value.trim();
if (!username) return;
const res = await api(`/players/${pid()}/force-bring`, "POST", { username });
if (res.ok) toast("Force brought to " + username + "'s room", "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-transfer-btn").addEventListener("click", () => {
if (!pid()) return;
const target = parseInt(document.getElementById("pa-transfer-target").value);
if (!target) return;
const keepCoOwner = document.getElementById("pa-transfer-coowner").checked;
const extra = keepCoOwner ? " The original owner will stay as co-owner." : "";
showConfirm("Transfer Rooms", `Transfer all rooms from player ${pid()} to player ${target}?${extra}`, async () => {
const res = await api(`/players/${pid()}/transfer-rooms`, "POST", { newPlayerId: target, keepCoOwner });
if (res.ok) toast(keepCoOwner ? "Rooms transferred, old owner kept as co-owner" : "Rooms transferred", "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("pa-sendws-btn").addEventListener("click", async () => {
if (!pid()) return;
const raw = (document.getElementById("pa-sendws")?.value || "").trim();
if (!raw) { toast("Enter a JSON payload first", "err"); return; }
try { JSON.parse(raw); } catch { toast("Invalid JSON", "err"); return; }
const res = await api(`/players/${pid()}/send-ws-json`, "POST", { Json: raw });
if (res.ok) toast("Sent to player websocket", "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-repair-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Repair Account", "Repair account for this player?", async () => {
const res = await api(`/players/${pid()}/repair`, "POST", {});
if (res.ok) { toast("Account repaired", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed", "err");
});
});
document.getElementById("pa-del-images-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Delete All Images", "Delete ALL images for this player? This cannot be undone.", async () => {
const res = await api(`/players/${pid()}/delete-all-images`, "POST", {});
if (res.ok) toast("Images deleted", "ok");
else toast("Failed", "err");
});
});
document.getElementById("pa-del-rooms-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Delete All Rooms", "Delete ALL rooms for this player? This cannot be undone.", async () => {
const res = await api(`/players/${pid()}/delete-all-rooms`, "POST", {});
if (res.ok) toast("Rooms deleted", "ok");
else toast("Failed", "err");
});
});
document.getElementById("pa-delete-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Delete Account", "Permanently delete this account? This cannot be undone.", async () => {
const res = await api(`/players/${pid()}/delete`, "DELETE");
if (res.ok) {
toast("Account deleted", "ok");
document.getElementById("player-detail").style.display = "none";
currentPlayer = null;
} else toast("Failed", "err");
});
});
document.querySelectorAll(".btn-role").forEach(btn => {
btn.addEventListener("click", async () => {
if (!pid()) return;
const role = btn.dataset.role;
const hasRole = btn.classList.contains("has-role");
const action = hasRole ? "remove" : "add";
const res = await api(`/players/${pid()}/roles`, "POST", { role, action });
if (res.ok) { toast("Role " + action + "ed: " + role, "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("pa-influencer-add").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/influencer`, "POST", { action: "add" });
if (res.ok) { toast("Influencer granted", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed", "err");
});
document.getElementById("pa-influencer-remove").addEventListener("click", async () => {
if (!pid()) return;
const res = await api(`/players/${pid()}/influencer`, "POST", { action: "remove" });
if (res.ok) { toast("Influencer removed", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed", "err");
});
document.getElementById("pa-ban-btn").addEventListener("click", () => {
if (!pid()) return;
const reason = document.getElementById("pa-ban-reason").value.trim() || "Banned by admin.";
const duration = parseInt(document.getElementById("pa-ban-duration").value) || 99999;
showConfirm("Ban Player", "Ban this player?", async () => {
const res = await api(`/players/${pid()}/ban`, "POST", { reason, duration });
if (res.ok) { toast("Player banned", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("pa-perm-ban-btn").addEventListener("click", () => {
if (!pid()) return;
const reason = document.getElementById("pa-ban-reason").value.trim() || "Banned by admin.";
showConfirm("Permanently Ban Player", "Permanently ban this player? Duration will be set to 2147483647 days.", async () => {
const res = await api(`/players/${pid()}/ban`, "POST", { reason, duration: 2147483647 });
if (res.ok) { toast("Player permanently banned", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("pa-unban-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Unban Player", "Unban this player?", async () => {
const res = await api(`/players/${pid()}/unban`, "POST", {});
if (res.ok) { toast("Player unbanned", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed", "err");
});
});
document.getElementById("pa-close-game-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Close Game", "Close this users game?", async () => {
const res = await api(`/players/${pid()}/close-game`, "POST", {});
if (res.ok) toast("Close game sent", "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("pa-logout-btn").addEventListener("click", () => {
if (!pid()) return;
showConfirm("Logout Player", "Logout this user?", async () => {
const res = await api(`/players/${pid()}/logout`, "POST", {});
if (res.ok) toast("Logged Out (dummy message)", "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("pa-player-coach-btn").addEventListener("click", async () => {
if (!pid()) return;
const message = document.getElementById("pa-player-coach-msg").value.trim();
if (!message) { toast("Enter a message", "err"); return; }
const res = await api(`/players/${pid()}/send-coach-message`, "POST", { message });
if (res.ok) {
toast("Coach message sent", "ok");
document.getElementById("pa-player-coach-msg").value = "";
} else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-token-btn").addEventListener("click", async () => {
if (!pid()) return;
const amount = parseInt(document.getElementById("pa-token-amount").value);
const balanceType = parseInt(document.getElementById("pa-token-type").value);
if (isNaN(amount)) return;
const res = await api(`/players/${pid()}/set-tokens`, "POST", { amount, balanceType });
if (res.ok) { toast("Tokens set to " + amount, "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("pa-skin-search").addEventListener("input", function () {
renderSkinList(this.value);
});
document.getElementById("pa-rmd-btn").addEventListener("click", () => {
if (!pid()) return;
const reporterVal = document.getElementById("pa-rmd-reporter").value.trim();
const timeoutVal = document.getElementById("pa-rmd-timeoutstartedat").value;
const body = {
reportCategory: parseInt(document.getElementById("pa-rmd-category").value),
duration: parseInt(document.getElementById("pa-rmd-duration").value) || 0,
gameSessionId: parseInt(document.getElementById("pa-rmd-gamesessionid").value) || 0,
message: document.getElementById("pa-rmd-message").value.trim() || null,
playerIdReporter: reporterVal ? parseInt(reporterVal) : null,
voteKickReason: document.getElementById("pa-rmd-votekickreason").value.trim() || null,
timeoutStartedAt: timeoutVal ? new Date(timeoutVal).toISOString() : null,
associatedAccountUsername: document.getElementById("pa-rmd-assocusername").value.trim() || null,
isBan: document.getElementById("pa-rmd-isban").value === "true",
isHostKick: document.getElementById("pa-rmd-ishostkick").value === "true",
isVoiceModAutoban: document.getElementById("pa-rmd-isvoicemodautoban").value === "true",
isDeviceBan: document.getElementById("pa-rmd-isdeviceban").value === "true",
isWarning: document.getElementById("pa-rmd-iswarning").value === "true",
bootToDorm: document.getElementById("pa-rmd-boottodorm").value === "true"
};
showConfirm("Apply Raw Mod Details", "Apply raw moderation details to this player?", async () => {
const res = await api(`/players/${pid()}/raw-mod-details`, "POST", body);
if (res.ok) { toast("Raw mod details applied", "ok"); await loadPlayerDetail(pid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
}
async function loadSkins() {
const res = await api("/skins");
if (res.ok) allSkins = res.data || [];
}
function renderSkinList(filter = "") {
const el = document.getElementById("pd-skin-list");
el.innerHTML = "";
const skins = filter ? allSkins.filter(s => (s.friendlyName || s.FriendlyName)?.toLowerCase().includes(filter.toLowerCase())) : allSkins.slice(0, 60);
skins.forEach(skin => {
const name = gv(skin, "friendlyName", "FriendlyName") || "Unknown";
const item = document.createElement("div");
item.className = "skin-item";
item.innerHTML = `<span>${name}</span><button class="btn-action" style="font-size:11px;padding:4px 8px">Give</button>`;
item.querySelector("button").addEventListener("click", async () => {
if (!currentPlayer?._id) return;
const res = await api(`/players/${currentPlayer._id}/give-skin`, "POST", { skin: name, action: "add" });
if (res.ok) { toast("Skin granted: " + name, "ok"); loadOwnedSkins(currentPlayer); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
el.appendChild(item);
});
}
function loadOwnedSkins(p) {
const extra = gv(p, "playerExtra", "PlayerExtra") || {};
const owned = gv(extra, "ownedEquipment", "OwnedEquipment") || gv(p, "ownedEquipment", "OwnedEquipment") || [];
const el = document.getElementById("pd-owned-list");
el.innerHTML = "";
owned.forEach(s => {
const name = gv(s, "friendlyName", "FriendlyName") || gv(s, "prefabName", "PrefabName") || "Unknown";
const chip = document.createElement("div");
chip.className = "owned-skin-chip";
chip.innerHTML = `${name} <span>✕</span>`;
chip.addEventListener("click", () => {
showConfirm("Remove Skin", "Remove " + name + "?", async () => {
const res = await api(`/players/${currentPlayer._id}/give-skin`, "POST", { skin: name, action: "remove" });
if (res.ok) { toast("Skin removed", "ok"); await loadPlayerDetail(currentPlayer._id); }
else toast("Failed", "err");
});
});
el.appendChild(chip);
});
if (!owned.length) el.innerHTML = '<span class="muted">No skins owned.</span>';
}
function setupRoomPanel() {
document.getElementById("room-search-btn").addEventListener("click", roomSearch);
document.getElementById("room-search-input").addEventListener("keydown", e => { if (e.key === "Enter") roomSearch(); });
document.getElementById("room-hot-btn").addEventListener("click", roomLoadHot);
document.getElementById("rd-close").addEventListener("click", () => {
document.getElementById("room-detail").style.display = "none";
currentRoom = null;
});
setupDetailTabs("#room-detail");
setupRoomActions();
}
async function roomSearch() {
const q = document.getElementById("room-search-input").value.trim();
if (!q) return;
if (/^\d+$/.test(q)) {
const res = await api(`/rooms/${BigInt(q)}`);
if (res.ok) { renderRoomResults([res.data]); return; }
toast("Room not found", "err"); return;
}
extendSession();
try {
const res = await api(`/rooms/search?q=${encodeURIComponent(q)}&skip=0&take=30`);
if (!res.ok) { toast("Search failed", "err"); return; }
renderRoomResults(res.data.Results || res.data.results || res.data);
} catch (e) {
toast("Search failed", "err");
return;
}
}
async function roomLoadHot() {
const res = await api("/rooms/hot?take=50");
if (!res.ok) { toast("Failed to load rooms", "err"); return; }
renderRoomResults(res.data.Results || res.data.results || res.data);
}
function renderRoomResults(rooms) {
const el = document.getElementById("room-results");
el.innerHTML = "";
if (!rooms || rooms.length === 0) {
el.innerHTML = '<div class="empty-state">No rooms found.</div>';
return;
}
const grid = document.createElement("div");
grid.className = "results-grid";
rooms.forEach(r => {
const rid = gv(r, "roomId", "RoomId");
const rname = gv(r, "name", "Name", "displayName", "DisplayName") || "Unknown";
const rimg = gv(r, "imageName", "ImageName");
const creator = gv(r, "creatorAccountId", "CreatorAccountId") || "?";
const statsObj = gv(r, "stats", "Stats") || {};
const visits = statsObj.visitCount ?? statsObj.VisitCount ?? 0;
const card = document.createElement("div");
card.className = "grid-card";
card.innerHTML = `
<img class="grid-card-thumb" src="${roomImgUrl(rimg)}" style="border-radius:0;border-bottom:1px solid var(--border)" onerror="this.src='/imageserver/DefaultRoomImage.png'">
<div class="grid-card-visits">${visits} visits</div>
<div class="grid-card-body">
<div class="grid-card-name">${rname}</div>
<div class="grid-card-sub">ID: ${rid} · Creator: ${creator}</div>
</div>`;
card.addEventListener("click", () => loadRoomDetail(rid));
grid.appendChild(card);
});
el.appendChild(grid);
}
async function loadRoomDetail(id) {
const res = await api(`/rooms/${id}`);
if (!res.ok) { toast("Failed to load room", "err"); return; }
currentRoom = res.data;
currentRoom._id = gv(res.data, "roomId", "RoomId");
renderRoomDetail(res.data);
document.getElementById("room-detail").style.display = "block";
document.getElementById("room-detail").scrollIntoView({ behavior: "smooth", block: "start" });
}
function populateSubroomSelect(r) {
const sel = document.getElementById("ra-blob-subroom-select");
sel.innerHTML = '<option value="">Select SubRoom…</option>';
const subs = gv(r, "subRooms", "SubRooms") || [];
subs.forEach(s => {
const sid = String(gv(s, "subRoomId", "SubRoomId"));
const sname = gv(s, "name", "Name") || "SubRoom";
const opt = document.createElement("option");
opt.value = sid;
opt.textContent = `${sname} (ID: ${sid})`;
sel.appendChild(opt);
});
}
function renderRoomDetail(r) {
const rid = r._id || gv(r, "roomId", "RoomId");
const rname = gv(r, "displayName", "DisplayName", "name", "Name") || "Unknown";
const rimg = gv(r, "imageName", "ImageName");
const creator = gv(r, "creatorAccountId", "CreatorAccountId") || "?";
const creatorUser = gv(r, "creatorUsername", "CreatorUsername");
document.getElementById("rd-img").src = roomImgUrl(rimg);
document.getElementById("rd-name").textContent = rname;
document.getElementById("rd-id").textContent = "ID: " + rid;
document.getElementById("rd-creator").textContent = "Creator ID: " + creator + (creatorUser ? " (@" + creatorUser + ")" : "");
const statsObj = gv(r, "stats", "Stats") || {};
const subRooms = gv(r, "subRooms", "SubRooms") || [];
infoGrid({
"Room ID": rid,
"Name": gv(r, "name", "Name"),
"Display Name": gv(r, "displayName", "DisplayName"),
"Description": gv(r, "description", "Description"),
"Creator ID": creator,
"Creator Username": creatorUser || "-",
"Max Players": gv(r, "maxPlayers", "MaxPlayers"),
"Accessibility": accessibilityName(gv(r, "accessibility", "Accessibility")),
"State": gv(r, "state", "State"),
"Age Rating": gv(r, "ageRating", "AgeRating"),
"Is RRO": (gv(r, "isRRO", "IsRRO")) ? "Yes" : "No",
"Cloning": (gv(r, "cloningAllowed", "CloningAllowed")) ? "Allowed" : "Disabled",
"Supports Mobile": (gv(r, "supportsMobile", "SupportsMobile")) ? "Yes" : "No",
"Created At": fmtDate(gv(r, "createdAt", "CreatedAt")),
"SubRooms": subRooms.length
}, "rd-info-grid");
const statsGrid = document.getElementById("rd-stats-grid");
if (statsGrid) {
statsGrid.innerHTML = "";
const statsData = {
"Visits": statsObj.visitCount ?? statsObj.VisitCount ?? 0,
"Cheers": statsObj.cheerCount ?? statsObj.CheerCount ?? 0,
"Favorites": statsObj.favoriteCount ?? statsObj.FavoriteCount ?? 0
};
for (const [k, v] of Object.entries(statsData)) {
const item = document.createElement("div");
item.className = "info-item";
item.innerHTML = `<div class="info-label">${k}</div><div class="info-val">${v}</div>`;
statsGrid.appendChild(item);
}
}
renderRoomTags(r);
renderSubRooms(r);
populateSubroomSelect(r);
const ra_name = document.getElementById("ra-name");
const ra_desc = document.getElementById("ra-desc");
if (ra_name) ra_name.value = gv(r, "name", "Name") || "";
if (ra_desc) ra_desc.value = gv(r, "description", "Description") || "";
const accessSel = document.getElementById("ra-access");
const currAccess = gv(r, "accessibility", "Accessibility");
if (accessSel && currAccess !== null) accessSel.value = currAccess;
const cloneSel = document.getElementById("ra-cloning");
if (cloneSel) cloneSel.value = (gv(r, "cloningAllowed", "CloningAllowed")) ? "true" : "false";
const ageSel = document.getElementById("ra-agerating");
if (ageSel) ageSel.value = gv(r, "ageRating", "AgeRating") ?? 0;
const maxEl = document.getElementById("ra-maxplayers");
if (maxEl) maxEl.value = gv(r, "maxPlayers", "MaxPlayers") ?? 8;
const mobileSel = document.getElementById("ra-mobile");
if (mobileSel) mobileSel.value = (gv(r, "supportsMobile", "SupportsMobile")) ? "true" : "false";
const screensSel = document.getElementById("ra-screens");
if (screensSel) screensSel.value = (gv(r, "supportsScreens", "SupportsScreens")) ? "true" : "false";
const walkvrSel = document.getElementById("ra-walkvr");
if (walkvrSel) walkvrSel.value = (gv(r, "supportsWalkVR", "SupportsWalkVR")) ? "true" : "false";
const teleportvrSel = document.getElementById("ra-teleportvr");
if (teleportvrSel) teleportvrSel.value = (gv(r, "supportsTeleportVR", "SupportsTeleportVR")) ? "true" : "false";
const juniorsSel = document.getElementById("ra-juniors");
if (juniorsSel) juniorsSel.value = (gv(r, "supportsJuniors", "SupportsJuniors")) ? "true" : "false";
}
const TAG_TYPE_NAMES = { 0: "General", 1: "Auto", 2: "AG Only", 3: "Banned" };
function renderRoomTags(r) {
const tags = gv(r, "tags", "Tags") || [];
const el = document.getElementById("rd-current-tags");
el.innerHTML = "";
if (!tags.length) {
el.innerHTML = '<span class="muted">No tags.</span>';
return;
}
tags.forEach(t => {
const tagName = gv(t, "tag", "Tag") || t;
const tagType = t.type ?? t.Type ?? 0;
const typeLabel = TAG_TYPE_NAMES[tagType] || `Type ${tagType}`;
const chip = document.createElement("span");
chip.className = "tag-chip";
chip.innerHTML = `${tagName}<span class="tag-type-badge type-${tagType}">${typeLabel}</span><button class="tag-remove-btn" title="Remove tag" data-tag="${tagName}">✕</button>`;
chip.querySelector(".tag-remove-btn").addEventListener("click", async (e) => {
e.stopPropagation();
const tag = e.currentTarget.dataset.tag;
const rid = currentRoom?._id;
if (!rid) return;
const res = await api(`/rooms/${rid}/tags`, "POST", { tags: [tag], action: "remove" });
if (res.ok) { toast("Tag removed", "ok"); await loadRoomDetail(rid); }
else toast("Failed to remove tag", "err");
});
el.appendChild(chip);
});
}
function renderSubRooms(r) {
const subs = gv(r, "subRooms", "SubRooms") || [];
const rid = r._id || gv(r, "roomId", "RoomId");
const el = document.getElementById("rd-subrooms-list");
el.innerHTML = "";
if (!subs.length) {
el.innerHTML = '<div class="empty-state">No subrooms.</div>';
return;
}
subs.forEach(s => {
const sid = gv(s, "subRoomId", "SubRoomId");
const card = document.createElement("div");
card.className = "subroom-card";
card.innerHTML = `
<div>
<div class="sr-name">${gv(s, "name", "Name") || "SubRoom"}</div>
<div class="sr-meta">ID: ${sid} · Max: ${gv(s, "maxPlayers", "MaxPlayers")} · ${accessibilityName(gv(s, "accessibility", "Accessibility"))}</div>
<div class="sr-meta">DataBlob: ${gv(s, "dataBlob", "DataBlob") || "none"}</div>
<div class="sr-meta sr-instances" data-subroom="${sid}">Instances: …</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px">
<button class="btn-danger sr-refresh-btn" data-subroom="${sid}">Refresh All Public Instances</button>
<button class="btn-action sr-makeprivate-btn" data-subroom="${sid}">Make All Instances Private</button>
<button class="btn-action sr-makepublic-btn" data-subroom="${sid}">Make All Instances Public</button>
</div>`;
card.querySelector(".sr-refresh-btn").addEventListener("click", () => {
const srName = gv(s, "name", "Name") || `SubRoom ${sid}`;
showConfirm(
"Refresh All Public Instances",
`Everyone in a public instance of "${srName}" will be moved into a brand new instance. Players currently together stay together. Private instances are untouched.`,
async () => {
const res = await api(`/rooms/${rid}/refresh-instances`, "POST", { subRoomId: sid });
if (res.ok && res.data?.success) {
const moved = res.data.playersMoved ?? 0;
const inst = res.data.instancesRefreshed ?? 0;
toast(inst ? `Refreshed ${inst} instance(s), moved ${moved} player(s)` : "No public instances to refresh", inst ? "ok" : "info");
loadSubRoomInstanceCounts(rid);
} else {
toast(res.data?.error || "Failed to refresh instances", "err");
}
}
);
});
card.querySelector(".sr-makeprivate-btn").addEventListener("click", () => {
const srName = gv(s, "name", "Name") || `SubRoom ${sid}`;
showConfirm(
"Make All Instances Private",
`Every public instance of "${srName}" will be switched to private, and players inside will stay together. Already-private instances are untouched.`,
async () => {
const res = await setSubRoomInstancesPrivacy(rid, sid, true);
if (res.ok && res.data?.success) {
const changed = res.data.instancesChanged ?? 0;
toast(changed ? `Made ${changed} instance(s) private` : "No public instances to change", changed ? "ok" : "info");
loadSubRoomInstanceCounts(rid);
} else {
toast(res.data?.error || "Failed to update instances", "err");
}
}
);
});
card.querySelector(".sr-makepublic-btn").addEventListener("click", () => {
const srName = gv(s, "name", "Name") || `SubRoom ${sid}`;
showConfirm(
"Make All Instances Public",
`Every private instance of "${srName}" will be switched to public, and players inside will stay together. Already-public instances are untouched.`,
async () => {
const res = await setSubRoomInstancesPrivacy(rid, sid, false);
if (res.ok && res.data?.success) {
const changed = res.data.instancesChanged ?? 0;
toast(changed ? `Made ${changed} instance(s) public` : "No private instances to change", changed ? "ok" : "info");
loadSubRoomInstanceCounts(rid);
} else {
toast(res.data?.error || "Failed to update instances", "err");
}
}
);
});
el.appendChild(card);
});
loadSubRoomInstanceCounts(rid);
}
async function setSubRoomInstancesPrivacy(roomId, subRoomId, isPrivate) {
return api(`/rooms/${roomId}/set-instances-privacy`, "POST", { subRoomId, isPrivate });
}
async function loadSubRoomInstanceCounts(roomId) {
const cells = document.querySelectorAll("#rd-subrooms-list .sr-instances");
if (!cells.length) return;
const res = await api(`/rooms/${roomId}/instances`);
if (!res.ok) {
cells.forEach(c => { c.textContent = "Instances: unavailable"; });
return;
}
const stats = {};
(res.data?.subRooms || []).forEach(x => { stats[String(x.subRoomId)] = x; });
cells.forEach(c => {
const st = stats[c.dataset.subroom];
const pub = st?.publicInstances ?? 0;
const pubPlayers = st?.publicPlayers ?? 0;
const priv = st?.privateInstances ?? 0;
const privPlayers = st?.privatePlayers ?? 0;
c.textContent = `Public: ${pub} instance${pub === 1 ? "" : "s"} (${pubPlayers} player${pubPlayers === 1 ? "" : "s"}) · Private: ${priv} instance${priv === 1 ? "" : "s"} (${privPlayers} player${privPlayers === 1 ? "" : "s"})`;
const card = c.closest(".subroom-card");
const refreshBtn = card?.querySelector(".sr-refresh-btn");
if (refreshBtn) refreshBtn.disabled = pub === 0;
const makePrivateBtn = card?.querySelector(".sr-makeprivate-btn");
if (makePrivateBtn) makePrivateBtn.disabled = pub === 0;
const makePublicBtn = card?.querySelector(".sr-makepublic-btn");
if (makePublicBtn) makePublicBtn.disabled = priv === 0;
});
}
function setupRoomImport() {
function wireImporter(triggerBtnId, endpoint, title) {
const modal = document.getElementById("import-room-modal");
const status = document.getElementById("import-status");
const nameInput = document.getElementById("import-room-name");
const submitBtn = document.getElementById("import-room-submit");
const resetModal = () => {
modal.style.display = "none";
status.style.display = "none";
status.textContent = "";
nameInput.value = "";
submitBtn.disabled = false;
submitBtn.onclick = null;
};
document.getElementById(triggerBtnId).addEventListener("click", () => {
modal.style.display = "flex";
document.getElementById("import-modal-title").textContent = title;
submitBtn.onclick = async () => {
const name = nameInput.value.trim();
if (!name) { toast("Room name is required", "err"); return; }
submitBtn.disabled = true;
status.style.display = "block";
status.textContent = "Importing…";
status.style.color = "var(--text-muted)";
try {
const res = await fetch(`/vannet/api/director/rooms/${endpoint}?name=${encodeURIComponent(name)}`, {
method: "POST",
headers: { "X-Director-Token": sessionToken || "" }
});
const data = await res.text().then(t => { try { return safeParse(t); } catch (_) { return {}; } }).catch(() => ({}));
if (res.ok && data.success) {
status.textContent = `Done - ${data.subRoomsImported} subroom(s) imported. Room ID: ${data.roomId} (${data.name})`;
status.style.color = "var(--success)";
toast("Room imported successfully", "ok");
nameInput.value = "";
} else {
status.textContent = "Error: " + (data.error || res.status);
status.style.color = "var(--danger-hover)";
toast("Import failed: " + (data.error || res.status), "err");
}
} catch (e) {
status.textContent = "Network error: " + e.message;
status.style.color = "var(--danger-hover)";
}
submitBtn.disabled = false;
};
});
document.getElementById("import-modal-close").addEventListener("click", resetModal);
modal.addEventListener("click", (e) => { if (e.target === modal) resetModal(); });
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submitBtn.click(); });
}
wireImporter("room-import-btn", "import-meownet", "Meow.Net Room Importer");
wireImporter("room-import-epicquest-btn", "import-epicquest", "EpicQuest Room Importer");
}
function setupRoomActions() {
const rid = () => currentRoom?._id;
document.getElementById("ra-name-btn").addEventListener("click", async () => {
if (!rid()) return;
const name = document.getElementById("ra-name").value.trim();
if (!name) return;
const res = await api(`/rooms/${rid()}/set-name`, "POST", { name });
if (res.ok) { toast("Name updated", "ok"); await loadRoomDetail(rid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("ra-desc-btn").addEventListener("click", async () => {
if (!rid()) return;
const description = document.getElementById("ra-desc").value;
const res = await api(`/rooms/${rid()}/set-description`, "POST", { description });
if (res.ok) { toast("Description updated", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-image-btn").addEventListener("click", async () => {
if (!rid()) return;
const imageName = document.getElementById("ra-image").value.trim();
if (!imageName) return;
const res = await api(`/rooms/${rid()}/set-image`, "POST", { imageName });
if (res.ok) { toast("Image updated", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-image-upload-btn").addEventListener("click", async () => {
if (!rid()) return;
const fileInput = document.getElementById("ra-image-upload");
if (!fileInput.files[0]) { toast("Select a file first", "err"); return; }
const fd = new FormData();
fd.append("file", fileInput.files[0]);
const res = await apiUpload(`/rooms/${rid()}/upload-image`, fd);
if (res.ok) {
toast("Image uploaded", "ok");
fileInput.value = "";
const preview = document.getElementById("room-img-upload-preview");
if (preview) preview.style.display = "none";
await loadRoomDetail(rid());
} else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("ra-image-reset-btn").addEventListener("click", () => {
if (!rid()) return;
showConfirm("Reset Image", "Reset room image to default?", async () => {
const res = await api(`/rooms/${rid()}/reset-image`, "POST", {});
if (res.ok) { toast("Image reset", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
});
document.getElementById("ra-access-btn").addEventListener("click", async () => {
if (!rid()) return;
const accessibility = parseInt(document.getElementById("ra-access").value);
const res = await api(`/rooms/${rid()}/set-accessibility`, "POST", { accessibility });
if (res.ok) { toast("Accessibility updated", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-cloning-btn").addEventListener("click", async () => {
if (!rid()) return;
const allowed = document.getElementById("ra-cloning").value === "true";
const res = await api(`/rooms/${rid()}/set-cloning`, "POST", { allowed });
if (res.ok) { toast("Cloning updated", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-restrictions-btn").addEventListener("click", async () => {
if (!rid()) return;
const supportsScreens = document.getElementById("ra-screens").value === "true";
const supportsWalkVR = document.getElementById("ra-walkvr").value === "true";
const supportsTeleportVR = document.getElementById("ra-teleportvr").value === "true";
const supportsJuniors = document.getElementById("ra-juniors").value === "true";
const supportsMobile = document.getElementById("ra-mobile").value === "true";
const res = await api(`/rooms/${rid()}/set-restrictions`, "POST", { supportsScreens, supportsWalkVR, supportsTeleportVR, supportsJuniors, supportsMobile });
if (res.ok) { toast("Platform settings saved", "ok"); await loadRoomDetail(rid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("ra-agerating-btn").addEventListener("click", async () => {
if (!rid()) return;
const ageRating = parseInt(document.getElementById("ra-agerating").value);
const res = await api(`/rooms/${rid()}/set-age-rating`, "POST", { ageRating });
if (res.ok) { toast("Age rating updated", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-maxplayers-btn").addEventListener("click", async () => {
if (!rid()) return;
const maxPlayers = parseInt(document.getElementById("ra-maxplayers").value);
if (!maxPlayers) return;
const res = await api(`/rooms/${rid()}/set-max-players`, "POST", { maxPlayers });
if (res.ok) { toast("Max players updated", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-owner-btn").addEventListener("click", () => {
if (!rid()) return;
const newOwnerId = parseInt(document.getElementById("ra-owner").value);
if (!newOwnerId) return;
showConfirm("Transfer Ownership", `Transfer room ${rid()} to player ${newOwnerId}?`, async () => {
const res = await api(`/rooms/${rid()}/change-owner`, "POST", { newOwnerId });
if (res.ok) { toast("Ownership transferred", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
});
document.getElementById("ra-role-btn").addEventListener("click", async () => {
if (!rid()) return;
const playerId = parseInt(document.getElementById("ra-role-player").value);
const role = parseInt(document.getElementById("ra-role-val").value);
if (!playerId) return;
const res = await api(`/rooms/${rid()}/set-role`, "POST", { playerId, role });
if (res.ok) { toast("Role set", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-beta-btn").addEventListener("click", async () => {
if (!rid()) return;
const res = await api(`/rooms/${rid()}/toggle-beta`, "POST", {});
if (res.ok) { toast("Beta tag toggled", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-blob-btn").addEventListener("click", async () => {
if (!rid()) return;
const subRoomId = document.getElementById("ra-blob-subroom-select").value;
const datablob = document.getElementById("ra-blob-name").value.trim();
if (!subRoomId || !datablob) { toast("Select a subroom and enter a datablob filename", "err"); return; }
const res = await api(`/rooms/${rid()}/change-datablob`, "POST", { subRoomId: subRoomId.toString(), datablob });
if (res.ok) { toast("DataBlob imported", "ok"); await loadRoomDetail(rid()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("ra-blob-upload").addEventListener("change", async () => {
if (!rid()) return;
const subRoomId = document.getElementById("ra-blob-subroom-select").value;
const fileInput = document.getElementById("ra-blob-upload");
if (!subRoomId) { toast("Select a subroom first", "err"); fileInput.value = ""; return; }
if (!fileInput.files[0]) return;
const fd = new FormData();
fd.append("file", fileInput.files[0]);
fd.append("subRoomId", subRoomId.toString());
const res = await apiUpload(`/rooms/${rid()}/upload-datablob`, fd);
if (res.ok) {
toast("DataBlob uploaded: " + res.data?.fileName, "ok");
fileInput.value = "";
await loadRoomDetail(rid());
} else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("ra-tag-add-btn").addEventListener("click", async () => {
if (!rid()) return;
const tag = document.getElementById("ra-tag-input").value.trim();
if (!tag) return;
const type = parseInt(document.getElementById("ra-tag-type").value);
const res = await api(`/rooms/${rid()}/tags`, "POST", { tags: [tag], action: "add", type });
if (res.ok) { toast("Tag added", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-tag-remove-btn").addEventListener("click", async () => {
if (!rid()) return;
const tag = document.getElementById("ra-tag-input").value.trim();
if (!tag) return;
const res = await api(`/rooms/${rid()}/tags`, "POST", { tags: [tag], action: "remove" });
if (res.ok) { toast("Tag removed", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-set-modern-persistence-btn").addEventListener("click", async () => {
if (!rid()) return;
const res = await api(`/rooms/${rid()}/set-modern-persistence`, "POST", {});
if (res.ok) { toast("Modern persistence values set", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-roomtype-btn").addEventListener("click", async () => {
if (!rid()) return;
const roomType = document.getElementById("ra-roomtype").value === "true";
const res = await api(`/rooms/${rid()}/set-room-type`, "POST", { roomType });
if (res.ok) { toast("Room type updated", "ok"); await loadRoomDetail(rid()); }
else toast("Failed", "err");
});
document.getElementById("ra-delete-btn").addEventListener("click", () => {
if (!rid()) return;
showConfirm("Delete Room", "Permanently delete this room? This cannot be undone.", async () => {
const res = await api(`/rooms/${rid()}/delete`, "DELETE");
if (res.ok) {
toast("Room deleted", "ok");
document.getElementById("room-detail").style.display = "none";
currentRoom = null;
} else toast("Failed", "err");
});
});
}
let currentImage = null;
let lastLoadedImages = [];
let showPrivateImages = false;
const IMAGE_TYPE_NAMES = {
0: "None",
1: "Share Camera",
2: "Outfit Thumbnail",
3: "Room Thumbnail",
4: "Profile Thumbnail",
5: "Invention Thumbnail",
6: "Player Event Thumbnail",
7: "Room Load Screen"
};
function imageTypeName(v) {
return IMAGE_TYPE_NAMES[v] ?? ("Type " + v);
}
function setupImagesPanel() {
document.getElementById("img-show-private").addEventListener("change", function () {
showPrivateImages = this.checked;
renderImages(lastLoadedImages);
});
document.getElementById("img-search-btn").addEventListener("click", async () => {
const pid = document.getElementById("img-player-id").value.trim();
if (!pid) return;
const res = await api(`/Images/player/${pid}`);
if (!res.ok) { toast("Failed to load images", "err"); return; }
lastLoadedImages = res.data || [];
renderImages(lastLoadedImages);
});
document.getElementById("img-global-btn").addEventListener("click", async () => {
const res = await api("/Images/global");
if (!res.ok) { toast("Failed", "err"); return; }
lastLoadedImages = res.data || [];
renderImages(lastLoadedImages);
});
document.getElementById("img-detail-close").addEventListener("click", closeImageDetail);
document.getElementById("img-detail-overlay").addEventListener("click", e => {
if (e.target === document.getElementById("img-detail-overlay")) closeImageDetail();
});
document.getElementById("imgd-access-btn").addEventListener("click", async () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
const val = parseInt(document.getElementById("imgd-access-select").value);
const res = await api(`/Images/${id}/set-accessibility`, "POST", { accessibility: val });
if (res.ok) { toast("Accessibility updated", "ok"); await reloadCurrentImage(id); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("imgd-lock-btn").addEventListener("click", async () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
const res = await api(`/Images/${id}/set-accessibility-lock`, "POST", { locked: true });
if (res.ok) { toast("Accessibility locked", "ok"); await reloadCurrentImage(id); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("imgd-unlock-btn").addEventListener("click", async () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
const res = await api(`/Images/${id}/set-accessibility-lock`, "POST", { locked: false });
if (res.ok) { toast("Accessibility unlocked", "ok"); await reloadCurrentImage(id); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("imgd-desc-btn").addEventListener("click", async () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
const desc = document.getElementById("imgd-desc-input").value;
const res = await api(`/Images/${id}/set-description`, "POST", { description: desc || null });
if (res.ok) { toast("Description updated", "ok"); await reloadCurrentImage(id); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("imgd-reset-cheers-btn").addEventListener("click", () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
showConfirm("Reset Cheers", "Reset all cheers on this image to 0?", async () => {
const res = await api(`/Images/${id}/reset-cheers`, "POST", {});
if (res.ok) { toast("Cheers reset", "ok"); await reloadCurrentImage(id); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("imgd-json-btn").addEventListener("click", () => {
const block = document.getElementById("imgd-json-block");
if (block.style.display === "none") {
block.textContent = JSON.stringify(currentImage, null, 2);
block.style.display = "block";
document.getElementById("imgd-json-btn").textContent = "Hide JSON";
} else {
block.style.display = "none";
document.getElementById("imgd-json-btn").textContent = "View Raw JSON";
}
});
document.getElementById("imgd-devlock-btn").addEventListener("click", async () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
showConfirm("Enable Dev Lock", "Lock this image so only developers can modify it bro?", async () => {
const res = await api(`/Images/${id}/set-dev-lock`, "POST", { devLocked: true });
if (res.ok) { toast("Dev lock enabled", "ok"); await reloadCurrentImage(id); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("imgd-devunlock-btn").addEventListener("click", async () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
const res = await api(`/Images/${id}/set-dev-lock`, "POST", { devLocked: false });
if (res.ok) { toast("Dev lock removed", "ok"); await reloadCurrentImage(id); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("imgd-transfer-btn").addEventListener("click", () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
const newPlayerId = parseInt(document.getElementById("imgd-transfer-id").value);
if (!newPlayerId) { toast("Enter a valid player ID", "err"); return; }
showConfirm("Transfer Image", `Transfer image ${id} to player ${newPlayerId}? The image will appear in their gallery.`, async () => {
const res = await api(`/Images/${id}/transfer`, "POST", { newPlayerId });
if (res.ok) {
toast("Image transferred to player " + newPlayerId, "ok");
document.getElementById("imgd-transfer-id").value = "";
await reloadCurrentImage(id);
} else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("imgd-copy-name-btn").addEventListener("click", () => {
if (!currentImage) return;
const name = gv(currentImage, "imageName", "ImageName") ?? "";
if (!name) { toast("No file name available", "err"); return; }
navigator.clipboard.writeText(name).then(() => {
toast("Copied: " + name, "ok");
}).catch(() => {
toast("Copy failed - try manually", "err");
});
});
document.getElementById("imgd-delete-btn").addEventListener("click", () => {
if (!currentImage) return;
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
showConfirm("Delete Image", "Permanently delete this image and all its cheers? This cannot be undone.", async () => {
const res = await api(`/Images/${id}/delete`, "POST", {});
if (res.ok) {
toast("Image deleted", "ok");
closeImageDetail();
const card = document.querySelector(`.img-card[data-id="${id}"]`);
if (card) card.remove();
} else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
}
async function reloadCurrentImage(id) {
const res = await api(`/Images/${id}`);
if (res.ok) {
currentImage = res.data;
renderImageDetail(res.data);
}
}
function closeImageDetail() {
document.getElementById("img-detail-overlay").style.display = "none";
currentImage = null;
}
async function openImageDetail(imgData) {
const id = imgData.id ?? imgData.Id ?? imgData.savedImageId ?? imgData.SavedImageId;
const res = await api(`/Images/${id}`);
const full = res.ok ? res.data : imgData;
currentImage = full;
renderImageDetail(full);
const overlay = document.getElementById("img-detail-overlay");
overlay.style.display = "flex";
}
function renderImageDetail(img) {
const name = gv(img, "imageName", "ImageName");
const id = img.id ?? img.Id ?? img.savedImageId ?? img.SavedImageId ?? "?";
const playerId = gv(img, "playerId", "PlayerId") ?? "?";
const roomId = gv(img, "roomId", "RoomId") ?? "?";
const type = img.type ?? img.Type ?? 0;
const access = img.accessibility ?? img.Accessibility ?? 0;
const locked = img.accessibilityLocked ?? img.AccessibilityLocked ?? false;
const cheerCount = img.cheerCount ?? img.CheerCount ?? 0;
const commentCount = img.commentCount ?? img.CommentCount ?? 0;
const createdAt = img.createdAt ?? img.CreatedAt;
const desc = img.description ?? img.Description;
const tagged = img.taggedPlayerIds ?? img.TaggedPlayerIds ?? [];
const playerEventId = gv(img, "playerEventId", "PlayerEventId");
document.getElementById("imgd-preview").src = `/imageserver/${name}`;
const typeBadge = document.getElementById("imgd-type-badge");
typeBadge.textContent = imageTypeName(type);
const accessBadge = document.getElementById("imgd-access-badge");
if (access === 1) {
accessBadge.textContent = "Public";
accessBadge.style.background = "rgba(39,174,96,0.18)";
accessBadge.style.border = "1px solid rgba(39,174,96,0.4)";
accessBadge.style.color = "var(--success)";
} else {
accessBadge.textContent = "Private";
accessBadge.style.background = "rgba(192,57,43,0.12)";
accessBadge.style.border = "1px solid rgba(192,57,43,0.3)";
accessBadge.style.color = "var(--danger)";
}
infoGrid({
"Image ID": id,
"Player ID": playerId,
"Room ID": roomId,
"Player Event ID": playerEventId ?? "-",
"Cheers": cheerCount,
"Comments": commentCount,
"Created": fmtDate(createdAt),
"Description": desc ?? "-"
}, "imgd-info-grid");
const accessSel = document.getElementById("imgd-access-select");
accessSel.value = String(access);
const lockStatus = document.getElementById("imgd-lock-status");
lockStatus.innerHTML = locked
? '<span class="badge badge-banned">Locked - player cannot change accessibility</span>'
: '<span class="badge badge-offline">Unlocked</span>';
document.getElementById("imgd-desc-input").value = desc ?? "";
const devLocked = img.devLocked ?? img.DevLocked ?? false;
const devLockEl = document.getElementById("imgd-devlock-status");
if (devLockEl) {
devLockEl.innerHTML = devLocked
? '<span class="badge badge-banned">Dev Locked - only developers can modify this image</span>'
: '<span class="badge badge-offline">No Dev Lock</span>';
}
const filenameEl = document.getElementById("imgd-filename-display");
if (filenameEl) {
filenameEl.textContent = name ? name : "-";
}
const taggedEl = document.getElementById("imgd-tagged-players");
taggedEl.innerHTML = "";
if (tagged.length) {
tagged.forEach(tpid => {
const chip = document.createElement("span");
chip.className = "chip";
chip.textContent = tpid;
chip.style.cursor = "pointer";
chip.title = "Open player profile";
chip.addEventListener("click", () => {
closeImageDetail();
loadPlayerDetail(tpid);
switchPanel("players");
});
taggedEl.appendChild(chip);
});
} else {
taggedEl.textContent = "No tagged players.";
}
const roomEl = document.getElementById("imgd-room-info");
if (roomId && roomId !== "?" && roomId !== 0) {
const btn = document.createElement("button");
btn.className = "btn-ghost";
btn.style.fontSize = "12px";
btn.textContent = "Room ID: " + roomId + " - Open Room";
btn.addEventListener("click", () => {
closeImageDetail();
loadRoomDetail(roomId);
switchPanel("rooms");
});
roomEl.innerHTML = "";
roomEl.appendChild(btn);
} else {
roomEl.textContent = "No room associated.";
}
const cheerEl = document.getElementById("imgd-cheer-info");
cheerEl.textContent = cheerCount + " cheer" + (cheerCount === 1 ? "" : "s");
const jsonBlock = document.getElementById("imgd-json-block");
jsonBlock.style.display = "none";
document.getElementById("imgd-json-btn").textContent = "View Raw JSON";
}
function renderImages(images) {
const el = document.getElementById("img-results");
el.innerHTML = "";
const sorted = [...images].sort((a, b) => {
const da = new Date(a.createdAt ?? a.CreatedAt ?? 0).getTime();
const db = new Date(b.createdAt ?? b.CreatedAt ?? 0).getTime();
return db - da;
});
const filtered = showPrivateImages
? sorted
: sorted.filter(img => (img.accessibility ?? img.Accessibility ?? 0) === 1);
if (!filtered.length) {
el.innerHTML = '<div class="empty-state">No images found.</div>';
return;
}
filtered.forEach(img => {
const name = gv(img, "imageName", "ImageName", "fileName", "FileName");
const id = img.savedImageId ?? img.SavedImageId ?? img.id ?? img.Id ?? "?";
const playerId = gv(img, "playerId", "PlayerId") ?? "?";
const access = img.accessibility ?? img.Accessibility ?? 0;
const type = img.type ?? img.Type ?? img.savedImageType ?? img.SavedImageType ?? 0;
const cheerCount = img.cheerCount ?? img.CheerCount ?? 0;
const locked = img.accessibilityLocked ?? img.AccessibilityLocked ?? false;
const card = document.createElement("div");
card.className = "img-card";
card.dataset.id = id;
card.innerHTML = `
<img src="/imageserver/${name}" onerror="this.src='/imageserver/DefaultPFP.png'" loading="lazy">
<div class="img-card-meta">
<div style="font-weight:700;color:var(--text);margin-bottom:2px">${imageTypeName(type)}</div>
<div>ID: ${id} · Player: ${playerId}</div>
<div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap">
<span style="padding:2px 5px;border-radius:3px;font-size:10px;background:${access === 1 ? "rgba(39,174,96,0.18)" : "rgba(192,57,43,0.12)"};color:${access === 1 ? "var(--success)" : "var(--danger)"};">${access === 1 ? "Public" : "Private"}</span>
${locked ? '<span style="padding:2px 5px;border-radius:3px;font-size:10px;background:rgba(0,0,0,0.1)">Locked</span>' : ""}
${cheerCount > 0 ? `<span style="padding:2px 5px;border-radius:3px;font-size:10px;background:rgba(242,101,34,0.12);color:var(--accent)">♥ ${cheerCount}</span>` : ""}
</div>
</div>`;
card.addEventListener("click", () => openImageDetail(img));
el.appendChild(card);
});
}
function setupDatabasePanel() {
const refreshBtn = document.getElementById("db-refresh-btn");
if (refreshBtn) refreshBtn.addEventListener("click", loadDbStats);
const execBtn = document.getElementById("db-exec-btn");
if (execBtn) execBtn.addEventListener("click", async () => {
const expression = document.getElementById("db-expr-input").value.trim();
if (!expression) return;
const res = await api("/db/execute", "POST", { expression });
const block = document.getElementById("db-exec-result");
block.classList.add("visible");
block.textContent = res.ok ? JSON.stringify(res.data.result ?? res.data, null, 2) : (res.data?.error || "Error: " + res.status);
});
const giftBtn = document.getElementById("db-gift-tokens-btn");
if (giftBtn) giftBtn.addEventListener("click", () => {
const amount = parseInt(document.getElementById("db-gift-amount").value);
const message = document.getElementById("db-gift-msg").value.trim() || null;
if (!amount || amount <= 0) { toast("Enter a valid amount", "err"); return; }
showConfirm("Gift All Players", `This lags the server a bit and also risky (Amount: ${amount})`, async () => {
const res = await api("/players/gift-all-tokens", "POST", { amount, message });
if (res.ok) toast(`Gifted ${amount} tokens to ${res.data?.playersGifted ?? "?"} players`, "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
loadDbStats();
}
async function loadDbStats() {
const res = await api("/db/info");
if (!res.ok) return;
const d = res.data;
document.getElementById("dbs-players").textContent = d.players ?? "-";
document.getElementById("dbs-rooms").textContent = d.rooms ?? "-";
document.getElementById("dbs-saves").textContent = d.saves ?? "-";
document.getElementById("dbs-inventions").textContent = d.inventions ?? "-";
}
function setupModerationPanel() {
document.getElementById("mod-ban-btn").addEventListener("click", () => {
const id = parseInt(document.getElementById("mod-ban-id").value);
const reason = document.getElementById("mod-ban-reason").value.trim() || "Banned by admin.";
const duration = parseInt(document.getElementById("mod-ban-days").value) || 99999;
if (!id) return;
showConfirm("Ban Player", `Ban player ${id}?`, async () => {
const res = await api(`/players/${id}/ban`, "POST", { reason, duration });
if (res.ok) toast("Player " + id + " banned", "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("mod-unban-btn").addEventListener("click", () => {
const id = parseInt(document.getElementById("mod-unban-id").value);
if (!id) return;
showConfirm("Unban Player", `Unban player ${id}?`, async () => {
const res = await api(`/players/${id}/unban`, "POST", {});
if (res.ok) toast("Player " + id + " unbanned", "ok");
else toast("Failed", "err");
});
});
document.getElementById("mod-shove-btn").addEventListener("click", async () => {
const pid = parseInt(document.getElementById("mod-shove-player").value);
const roomId = parseInt(document.getElementById("mod-shove-room").value);
const subRoomIdVal = document.getElementById("mod-shove-subroom").value;
const subRoomId = subRoomIdVal ? parseInt(subRoomIdVal) : null;
const instanceId = document.getElementById("mod-shove-instance").value.trim() || null;
if (!pid || !roomId) return;
const res = await api(`/players/${pid}/shove`, "POST", { roomId, subRoomId, instanceId });
if (res.ok) toast(`Player ${pid} shoved to room ${roomId}`, "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("mod-hb-btn").addEventListener("click", () => {
const id = parseInt(document.getElementById("mod-hb-id").value);
if (!id) return;
showConfirm("Clear Heartbeat", `Clear heartbeat for player ${id}?`, async () => {
const res = await api(`/players/${id}/heartbeat-clear`, "POST", {});
if (res.ok) toast("Heartbeat cleared", "ok");
else toast("Failed", "err");
});
});
document.getElementById("mod-dragall-btn").addEventListener("click", () => {
const roomId = parseInt(document.getElementById("mod-dragall-room").value);
const subRoomIdVal = document.getElementById("mod-dragall-subroom").value;
const subRoomId = subRoomIdVal ? parseInt(subRoomIdVal) : null;
const instanceId = document.getElementById("mod-dragall-instance").value.trim() || null;
if (!roomId) return;
showConfirm("Drag All Online", `Drag all online players to room ${roomId}?`, async () => {
const res = await api("/players/shove-all", "POST", { roomId, subRoomId, instanceId });
if (res.ok) toast(`Dragged ${res.data?.playersShoved ?? "?"} players to room ${roomId}`, "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
}
let modBoardPlayer = null;
function setupModBoardPanel() {
const mbId = () => modBoardPlayer?._id;
async function mbLoadPlayer(id) {
const res = await api(`/players/${id}`);
if (!res.ok) { toast("Player not found", "err"); return; }
modBoardPlayer = res.data;
modBoardPlayer._id = gv(res.data, "accountId", "playerId", "AccountId", "PlayerId");
renderModBoardCard(res.data);
}
async function mbLookup() {
const q = document.getElementById("mb-lookup-input").value.trim();
if (!q) return;
if (/^\d+$/.test(q)) { await mbLoadPlayer(q); return; }
const res = await api(`/players/search?q=${encodeURIComponent(q)}&take=50`);
if (!res.ok) { toast("Search failed", "err"); return; }
const results = res.data.Results || res.data.results || res.data || [];
const exact = results.find(r => (gv(r, "username", "Username") || "").toLowerCase() === q.toLowerCase());
const pick = exact || results[0];
if (!pick) { toast("No player found", "err"); return; }
await mbLoadPlayer(gv(pick, "accountId", "playerId", "AccountId", "PlayerId"));
}
document.getElementById("mb-lookup-btn").addEventListener("click", mbLookup);
document.getElementById("mb-lookup-input").addEventListener("keydown", e => {
if (e.key === "Enter") mbLookup();
});
document.getElementById("mb-close").addEventListener("click", () => {
modBoardPlayer = null;
document.getElementById("mb-player-card").style.display = "none";
});
document.getElementById("mb-voiceban-btn").addEventListener("click", async () => {
if (!mbId()) return;
const res = await api(`/players/${mbId()}/toxmod/ban`, "POST", { category: "Other", loud: false });
if (res.ok) { toast("Voice ban issued", "ok"); await mbLoadPlayer(mbId()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("mb-voiceunban-btn").addEventListener("click", async () => {
if (!mbId()) return;
const res = await api(`/players/${mbId()}/toxmod/unban`, "POST", { resetStrikes: false });
if (res.ok) { toast("Voice ban lifted", "ok"); await mbLoadPlayer(mbId()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("mb-ban-btn").addEventListener("click", () => {
if (!mbId()) return;
showConfirm("Ban Player", `Ban player ${mbId()}?`, async () => {
const res = await api(`/players/${mbId()}/ban`, "POST", { reason: "Banned by a moderator.", duration: 99999 });
if (res.ok) { toast("Player banned", "ok"); await mbLoadPlayer(mbId()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("mb-unban-btn").addEventListener("click", async () => {
if (!mbId()) return;
const res = await api(`/players/${mbId()}/unban`, "POST", {});
if (res.ok) { toast("Player unbanned", "ok"); await mbLoadPlayer(mbId()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("mb-junior-btn").addEventListener("click", async () => {
if (!mbId()) return;
const reason = document.getElementById("mb-junior-reason").value.trim() || null;
const res = await api(`/players/${mbId()}/set-junior`, "POST", { isJunior: true, reason });
if (res.ok) {
toast("Comms revoked - player notified", "ok");
document.getElementById("mb-junior-reason").value = "";
await mbLoadPlayer(mbId());
}
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("mb-unjunior-btn").addEventListener("click", async () => {
if (!mbId()) return;
const res = await api(`/players/${mbId()}/set-junior`, "POST", { isJunior: false });
if (res.ok) { toast("Comms restored", "ok"); await mbLoadPlayer(mbId()); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("mb-close-game-btn").addEventListener("click", async () => {
if (!mbId()) return;
const res = await api(`/players/${mbId()}/close-game`, "POST", {});
if (res.ok) toast("Game closed", "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("mb-hb-clear-btn").addEventListener("click", () => {
if (!mbId()) return;
showConfirm("Clear Heartbeat", `Clear heartbeat for player ${mbId()}?`, async () => {
const res = await api(`/players/${mbId()}/heartbeat-clear`, "POST", {});
if (res.ok) { toast("Heartbeat cleared", "ok"); await mbLoadPlayer(mbId()); }
else toast("Failed", "err");
});
});
document.getElementById("mb-full-profile-btn").addEventListener("click", async () => {
if (!mbId()) return;
await loadPlayerDetail(mbId());
switchPanel("players");
});
document.getElementById("mb-dm-send-btn").addEventListener("click", async () => {
if (!mbId()) return;
const msg = document.getElementById("mb-dm-text").value.trim();
if (!msg) { toast("Enter a message first", "err"); return; }
const res = await api(`/players/${mbId()}/coach-dm`, "POST", { message: msg });
if (res.ok) { toast("DM sent as Coach", "ok"); document.getElementById("mb-dm-text").value = ""; }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("mb-coach-popup-btn").addEventListener("click", async () => {
if (!mbId()) return;
const msg = document.getElementById("mb-dm-text").value.trim();
if (!msg) { toast("Enter a message first", "err"); return; }
const res = await api(`/players/${mbId()}/send-coach-message`, "POST", { message: msg });
if (res.ok) { toast("Popup sent as Coach", "ok"); document.getElementById("mb-dm-text").value = ""; }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
}
function renderModBoardCard(p) {
const id = p._id || gv(p, "accountId", "playerId", "AccountId", "PlayerId");
const name = gv(p, "displayName", "username", "DisplayName", "Username") || "Unknown";
const username = gv(p, "username", "Username") || "?";
const img = gv(p, "profileImage", "ProfileImage");
const hb = gv(p, "heartbeat") || {};
const bd = gv(p, "moderationBlockDetails", "ModerationBlockDetails") || {};
const tm = gv(p, "toxMod", "ToxMod") || {};
document.getElementById("mb-pfp").src = pfpUrl(img);
document.getElementById("mb-name").textContent = name;
document.getElementById("mb-username").textContent = "@" + username;
document.getElementById("mb-id").textContent = "ID: " + id;
const badges = [];
badges.push(hb.isOnline
? '<span class="badge badge-online">Online</span>'
: '<span class="badge badge-offline">Offline</span>');
if (bd.isBan || bd.IsBan) badges.push('<span class="badge badge-banned">Banned</span>');
if (bd.isVoiceModAutoban || bd.IsVoiceModAutoban) badges.push('<span class="badge badge-banned">Voice Banned</span>');
if (gv(p, "isJunior", "IsJunior")) badges.push('<span class="badge badge-banned">Comms Revoked (Junior)</span>');
const strikes = tm.strikes ?? tm.Strikes ?? 0;
if (strikes) badges.push(`<span class="badge badge-offline">${strikes} strike${strikes === 1 ? "" : "s"}</span>`);
if (gv(p, "discordLinked", "DiscordLinked") || gv(p, "discordUserId", "DiscordUserId"))
badges.push(`<span class="badge badge-online">Discord: ${discordLabel(p)}</span>`);
document.getElementById("mb-badges").innerHTML = badges.join(" ");
document.getElementById("mb-player-card").style.display = "block";
}
async function loadAnnouncements() {
const el = document.getElementById("ann-list");
if (!el) return;
const res = await api("/announcements");
const list = res.ok && Array.isArray(res.data) ? res.data : [];
el.innerHTML = "";
if (!list.length) {
el.innerHTML = '<div class="muted">No active announcements.</div>';
return;
}
list.forEach(a => {
const id = gv(a, "announcementId", "AnnouncementId");
const title = gv(a, "title", "Title") || "(no title)";
const body = gv(a, "body", "Body") || "";
const row = document.createElement("div");
row.className = "field-row";
row.style.alignItems = "center";
const info = document.createElement("div");
info.style.flex = "1";
const t = document.createElement("strong");
t.textContent = title;
const b = document.createElement("div");
b.className = "muted";
b.style.fontSize = "12px";
b.textContent = body.length > 120 ? body.slice(0, 120) + "…" : body;
info.appendChild(t);
info.appendChild(b);
const btn = document.createElement("button");
btn.className = "btn-danger";
btn.textContent = "Cancel";
btn.addEventListener("click", async () => {
const r = await api(`/announcement/delete/${id}`, "POST", {});
if (r.ok) { toast("Announcement cancelled", "ok"); await loadAnnouncements(); }
else toast("Failed: " + (r.data?.error || r.status), "err");
});
row.appendChild(info);
row.appendChild(btn);
el.appendChild(row);
});
}
function setupServerPanel() {
document.getElementById("ann-send-btn").addEventListener("click", async () => {
const title = document.getElementById("ann-title").value.trim();
const body = document.getElementById("ann-body").value.trim();
if (!title && !body) { toast("Title or body required", "err"); return; }
const res = await api("/announcement", "POST", {
AnnouncementType: document.getElementById("ann-type").value,
Title: document.getElementById("ann-title").value.trim() || null,
Body: document.getElementById("ann-body").value.trim() || null,
ImageName: document.getElementById("ann-image").value.trim() || null,
LinkType: document.getElementById("ann-linktype").value,
LinkName: document.getElementById("ann-linkname").value.trim() || null,
LinkUri: document.getElementById("ann-linkuri").value.trim() || null
});
if (res.ok) { toast("Announcement published", "ok"); await loadAnnouncements(); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("ann-clear-btn").addEventListener("click", () => {
showConfirm("Clear Announcement", "Remove all announcements so players see none?", async () => {
const res = await api("/announcement", "POST", { clear: true });
if (res.ok) { toast("Announcement cleared", "ok"); await loadAnnouncements(); }
else toast("Failed", "err");
});
});
document.getElementById("ann-refresh-btn").addEventListener("click", loadAnnouncements);
loadAnnouncements();
(async () => {
const res = await api("/community-banner");
if (res.ok) {
document.getElementById("cbb-message").value = res.data?.message || "";
document.getElementById("cbb-url").value = res.data?.moreInfoUrl || "";
}
})();
(async () => {
const res = await api("/system-prompt");
if (res.ok) {
document.getElementById("ccsp-message").value = res.data?.message || "";
}
})();
document.getElementById("crash-all-btn").addEventListener("click", () => {
showConfirm("Crash Everyone", "Send a crash websocket event to all online non-developer players?", async () => {
const res = await api("/players/close-all", "POST", {});
if (res.ok) toast(`Closed ${res.data?.playersCrashed ?? "?"} player(s)`, "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
document.getElementById("ccsp-send-btn").addEventListener("click", async () => {
extendSession();
const message = document.getElementById("ccsp-message").value.trim();
try {
const res = await fetch(BASE + "/system-prompt", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Director-Token": sessionToken || ""
},
body: JSON.stringify(message)
});
if (res.status === 401 || res.status === 403) { forceLogout("Your session is no longer valid."); return; }
if (res.ok) toast("System prompt updated", "ok");
else toast("Failed: " + res.status, "err");
} catch (e) {
toast("Failed: " + e.message, "err");
}
});
document.getElementById("cbb-send-btn").addEventListener("click", async () => {
const message = document.getElementById("cbb-message").value.trim();
const moreInfoUrl = document.getElementById("cbb-url").value.trim();
const res = await api("/community-banner", "POST", { message, moreInfoUrl });
if (res.ok) toast("Community board banner updated", "ok");
else toast("Failed: " + (res.data?.error || res.status), "err");
});
document.getElementById("db-maint-btn").addEventListener("click", () => {
const mins = parseInt(document.getElementById("db-maint-minutes").value) || 10;
showConfirm("Send Maintenance Alert", `Broadcast maintenance alert for ${mins} minutes to all connected clients?`, async () => {
const res = await api("/maintenance/schedule", "POST", { minutes: mins });
if (res.ok) toast("Maintenance alert sent", "ok");
else toast("Failed", "err");
});
});
document.getElementById("db-clear-rooms-btn").addEventListener("click", () => {
showConfirm("Clear Rooms", "Clear all non-dorm rooms and re-import from ImportRooms.json?", async () => {
const res = await api("/rooms/clear", "POST", {});
if (res.ok) toast("Rooms cleared and re-imported", "ok");
else toast("Failed", "err");
});
});
document.getElementById("db-reset-btn").addEventListener("click", () => {
showConfirm("WIPE Database", "This will permanently wipe ALL players, rooms, inventions, friends, and events. This cannot be undone.", async () => {
const res = await api("/db/reset", "POST", {});
if (res.ok) { toast("Database wiped", "ok"); await loadDbStats(); }
else toast("Failed", "err");
});
});
document.getElementById("coach-btn").addEventListener("click", async () => {
const coachMsgValue = document.getElementById("coach-msg").value;
const res = await api("/coach/send-msg", "POST", { message: coachMsgValue });
if (res.ok) {
toast("Message sent!", "ok");
document.getElementById("coach-msg").value = "";
await loadDbStats();
} else {
toast("Failed", "err");
}
});
}
function switchPanel(name) {
document.querySelectorAll(".nav-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
document.querySelector(`.nav-btn[data-panel="${name}"]`)?.classList.add("active");
document.getElementById("panel-" + name)?.classList.add("active");
}
function fmtDate(val) {
if (!val) return "-";
try { return new Date(val).toLocaleString(); } catch { return val; }
}
function accessibilityName(v) {
const m = { 0: "Private", 1: "Public", 2: "Unlisted", 3: "Dev Only", 4: "Dev Unlisted" };
return m[v] ?? v ?? "-";
}
// --- Anti-Cheat ---------------------------------------------------------------
// Hardware bans, and the machine link table behind them. The panel is built around
// one idea: a machine ID is shared by everyone who has used that computer, so the
// linked account list is shown first and the ban button sits underneath it.
function setupAntiCheatPanel() {
document.getElementById("ac-search-btn").addEventListener("click", acSearch);
document.getElementById("ac-search-input").addEventListener("keydown", e => {
if (e.key === "Enter") acSearch();
});
document.getElementById("ac-list-banned-btn").addEventListener("click", acListBanned);
document.getElementById("ac-ban-btn").addEventListener("click", () => {
const hwid = document.getElementById("ac-ban-hwid").value.trim();
const playerId = parseInt(document.getElementById("ac-ban-player").value) || null;
const reason = document.getElementById("ac-ban-reason").value.trim();
const daysVal = document.getElementById("ac-ban-days").value.trim();
const durationDays = daysVal ? parseInt(daysVal) : null;
if (!hwid && !playerId) { toast("Give a machine ID or a player ID", "err"); return; }
if (!reason) { toast("A reason is required", "err"); return; }
const what = hwid ? `machine ${acShort(hwid)}` : `every machine player ${playerId} uses`;
const term = durationDays ? `${durationDays} day(s)` : "permanently";
showConfirm("Ban Machine",
`Ban ${what} ${term}? Every account linked to it will be banned too.`,
() => acBan({ hwid: hwid || null, playerId, reason, durationDays }));
});
document.getElementById("ac-unban-btn").addEventListener("click", () => {
const hwid = document.getElementById("ac-unban-hwid").value.trim();
if (!hwid) return;
showConfirm("Lift Hardware Ban",
`Lift the ban on machine ${acShort(hwid)}? Accounts banned with it stay banned.`,
async () => {
const res = await api("/anticheat/machines/unban", "POST", { hwid });
if (res.ok) { toast("Hardware ban lifted", "ok"); acListBanned(); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
}
async function acBan(body) {
const res = await api("/anticheat/machines/ban", "POST", body);
if (!res.ok) { toast("Failed: " + (res.data?.error || res.status), "err"); return; }
const machines = res.data?.hwids?.length ?? 0;
const accounts = res.data?.bannedAccounts?.length ?? 0;
toast(`Banned ${machines} machine(s), ${accounts} account(s)`, "ok");
acListBanned();
}
async function acSearch() {
const raw = document.getElementById("ac-search-input").value.trim();
if (!raw) return;
// A machine ID is a long hash and a player ID is a number, so the box takes either
// and works out which one it was given.
const query = /^\d+$/.test(raw) ? `playerId=${raw}` : `hwid=${encodeURIComponent(raw)}`;
const res = await api(`/anticheat/machines?${query}`);
if (!res.ok) { toast("Lookup failed: " + (res.data?.error || res.status), "err"); return; }
acRenderMachines(res.data, "No machine on record for that.");
}
async function acListBanned() {
const res = await api("/anticheat/machines/banned");
if (!res.ok) { toast("Failed to load banned machines", "err"); return; }
acRenderMachines(res.data, "No machines are hardware banned.");
}
function acShort(hwid) {
return !hwid ? "?" : (hwid.length > 20 ? hwid.slice(0, 10) + "…" + hwid.slice(-6) : hwid);
}
function acDate(v) {
if (!v) return "-";
const d = new Date(v);
return isNaN(d) ? "-" : d.toLocaleString();
}
function acRenderMachines(machines, emptyText) {
const el = document.getElementById("ac-results");
el.innerHTML = "";
if (!machines || machines.length === 0) {
el.innerHTML = `<div class="empty-state">${emptyText}</div>`;
return;
}
const list = document.createElement("div");
list.className = "results-list";
machines.forEach(m => list.appendChild(acMachineCard(m)));
el.appendChild(list);
}
function acMachineCard(m) {
const card = document.createElement("div");
card.className = "ac-machine" + (m.banned ? " ac-machine-banned" : "");
const players = m.players || [];
const bannedCount = players.filter(p => p.banned).length;
const head = document.createElement("div");
head.className = "ac-machine-head";
head.innerHTML = `
<div>
<div class="ac-hwid" title="${m.hwid || ""}">${acShort(m.hwid)}</div>
<div class="muted">Seen ${acDate(m.firstSeen)} → ${acDate(m.lastSeen)}</div>
</div>
<div class="ac-machine-badges">
${m.banned ? '<span class="badge badge-banned">Banned</span>' : ""}
<span class="chip">${players.length} account${players.length === 1 ? "" : "s"}</span>
${bannedCount ? `<span class="chip">${bannedCount} already banned</span>` : ""}
</div>`;
card.appendChild(head);
if (m.banned) {
const ban = document.createElement("div");
ban.className = "ac-ban-detail";
ban.innerHTML = `
<div><strong>Reason:</strong> ${m.banReason || "-"}</div>
<div><strong>By:</strong> ${m.bannedBy || "-"} · ${acDate(m.bannedAt)}</div>
<div><strong>Expires:</strong> ${m.banExpiresAt ? acDate(m.banExpiresAt) : "never"}</div>`;
card.appendChild(ban);
}
const accounts = document.createElement("div");
accounts.className = "ac-accounts";
if (players.length === 0) {
accounts.innerHTML = '<div class="muted">No accounts linked yet.</div>';
} else {
players.forEach(p => {
const chip = document.createElement("button");
chip.className = "ac-account" + (p.banned ? " ac-account-banned" : "");
chip.innerHTML = `
<img src="${pfpUrl(p.profileImage)}" onerror="this.src='/imageserver/DefaultPFP.png'">
<span>${p.username || "Unknown"}</span>
<span class="muted">${p.playerId}</span>`;
// Straight through to the player, because the next thing a moderator wants after
// seeing a name on a machine is that account.
chip.addEventListener("click", () => {
document.querySelector('.nav-btn[data-panel="players"]').click();
loadPlayerDetail(p.playerId);
});
accounts.appendChild(chip);
});
}
card.appendChild(accounts);
card.appendChild(acMachineActions(m));
return card;
}
function acMachineActions(m) {
const row = document.createElement("div");
row.className = "ac-machine-actions";
const fill = document.createElement("button");
fill.className = "btn-ghost";
fill.textContent = "Copy ID to form";
fill.addEventListener("click", () => {
document.getElementById("ac-ban-hwid").value = m.hwid;
document.getElementById("ac-unban-hwid").value = m.hwid;
toast("Machine ID copied into the ban form");
});
row.appendChild(fill);
if (m.banned) {
const unban = document.createElement("button");
unban.className = "btn-action";
unban.textContent = "Lift ban";
unban.addEventListener("click", () => {
showConfirm("Lift Hardware Ban",
`Lift the ban on machine ${acShort(m.hwid)}? Accounts banned with it stay banned.`,
async () => {
const res = await api("/anticheat/machines/unban", "POST", { hwid: m.hwid });
if (res.ok) { toast("Hardware ban lifted", "ok"); acListBanned(); }
else toast("Failed: " + (res.data?.error || res.status), "err");
});
});
row.appendChild(unban);
} else {
const ban = document.createElement("button");
ban.className = "btn-danger";
ban.textContent = "Ban this machine";
ban.addEventListener("click", () => {
const reason = document.getElementById("ac-ban-reason").value.trim();
if (!reason) {
toast("Fill in a reason below first", "err");
document.getElementById("ac-ban-reason").focus();
return;
}
const daysVal = document.getElementById("ac-ban-days").value.trim();
const durationDays = daysVal ? parseInt(daysVal) : null;
const count = (m.players || []).length;
showConfirm("Ban Machine",
`Ban machine ${acShort(m.hwid)}? ${count} linked account(s) will be banned with it.`,
() => acBan({ hwid: m.hwid, playerId: null, reason, durationDays }));
});
row.appendChild(ban);
}
return row;
}