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(/(? 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 = `
⬡
${msg || "Session ended."}
Redirecting to authorization…
`; 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 = `
${k}
${v ?? "-"}
`; 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", ` `); 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", ` `); 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 = '
No players found.
'; 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 = `
${name}
@${username} · Lv ${level}
ID: ${id}
`; 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 = 'No roles assigned.'; } 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 = 'Not Banned'; } else { el.innerHTML = `Banned
Reason: ${bd.message || bd.Message || "-"}
Duration: ${bd.duration || bd.Duration || "-"} days
`; } 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 ? 'Voice Banned' : 'Voice OK'; if (banned) { if (category) html += `
Offense: ${category}
`; html += `
Expires: ${expiresAt ? fmtToxModRemaining(expiresAt) : "never (manual lift required)"}
`; } html += `
ToxMod strikes: ${strikes}
`; toxEl.innerHTML = html; } const juniorEl = document.getElementById("pa-junior-status"); if (juniorEl) { const isJr = gv(p, "isJunior", "IsJunior"); let html = isJr ? 'Comms Restricted (Junior)' : 'Not Junior'; 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 += `
Reason: ${reason}
`; if (by || at) html += `
By: ${by || "?"}${at ? " · " + fmtDate(at) : ""}
`; } juniorEl.innerHTML = html; } const nameLockEl = document.getElementById("pa-namelock-status"); if (nameLockEl) { const locked = gv(p, "nameLocked", "NameLocked"); nameLockEl.innerHTML = locked ? 'Name Locked' : 'Name Unlocked'; } const rrplusEl = document.getElementById("pa-rrplus-status"); if (rrplusEl) { const has = gv(p, "recRoomPlus", "RecRoomPlus"); rrplusEl.innerHTML = has ? 'Rec Room Plus' : 'No RR+'; } } 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 = '
No rooms found.
'; 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 = `
${rname}
ID: ${rid} · ${visits} visits
`; 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 = `${name}`; 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} ✕`; 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 = 'No skins owned.'; } 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 = '
No rooms found.
'; 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 = `
${visits} visits
${rname}
ID: ${rid} · Creator: ${creator}
`; 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 = ''; 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 = `
${k}
${v}
`; 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 = 'No tags.'; 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}${typeLabel}`; 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 = '
No subrooms.
'; return; } subs.forEach(s => { const sid = gv(s, "subRoomId", "SubRoomId"); const card = document.createElement("div"); card.className = "subroom-card"; card.innerHTML = `
${gv(s, "name", "Name") || "SubRoom"}
ID: ${sid} · Max: ${gv(s, "maxPlayers", "MaxPlayers")} · ${accessibilityName(gv(s, "accessibility", "Accessibility"))}
DataBlob: ${gv(s, "dataBlob", "DataBlob") || "none"}
Instances: …
`; 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 ? 'Locked - player cannot change accessibility' : 'Unlocked'; 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 ? 'Dev Locked - only developers can modify this image' : 'No Dev Lock'; } 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 = '
No images found.
'; 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 = `
${imageTypeName(type)}
ID: ${id} · Player: ${playerId}
${access === 1 ? "Public" : "Private"} ${locked ? 'Locked' : ""} ${cheerCount > 0 ? `♥ ${cheerCount}` : ""}
`; 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 ? 'Online' : 'Offline'); if (bd.isBan || bd.IsBan) badges.push('Banned'); if (bd.isVoiceModAutoban || bd.IsVoiceModAutoban) badges.push('Voice Banned'); if (gv(p, "isJunior", "IsJunior")) badges.push('Comms Revoked (Junior)'); const strikes = tm.strikes ?? tm.Strikes ?? 0; if (strikes) badges.push(`${strikes} strike${strikes === 1 ? "" : "s"}`); if (gv(p, "discordLinked", "DiscordLinked") || gv(p, "discordUserId", "DiscordUserId")) badges.push(`Discord: ${discordLabel(p)}`); 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 = '
No active announcements.
'; 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 = `
${emptyText}
`; 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 = `
${acShort(m.hwid)}
Seen ${acDate(m.firstSeen)} → ${acDate(m.lastSeen)}
${m.banned ? 'Banned' : ""} ${players.length} account${players.length === 1 ? "" : "s"} ${bannedCount ? `${bannedCount} already banned` : ""}
`; card.appendChild(head); if (m.banned) { const ban = document.createElement("div"); ban.className = "ac-ban-detail"; ban.innerHTML = `
Reason: ${m.banReason || "-"}
By: ${m.bannedBy || "-"} · ${acDate(m.bannedAt)}
Expires: ${m.banExpiresAt ? acDate(m.banExpiresAt) : "never"}
`; card.appendChild(ban); } const accounts = document.createElement("div"); accounts.className = "ac-accounts"; if (players.length === 0) { accounts.innerHTML = '
No accounts linked yet.
'; } else { players.forEach(p => { const chip = document.createElement("button"); chip.className = "ac-account" + (p.banned ? " ac-account-banned" : ""); chip.innerHTML = ` ${p.username || "Unknown"} ${p.playerId}`; // 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; }