Files
Campus-25/server/Server/2025-RecRoom-Server-E12354-main/Web/Admin/Home/HomeV4.html
T
2026-10-14 06:59:51 +02:00

5166 lines
297 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1">
<title>Campus Admin Panel</title>
<link rel="icon" href="/admin-assets/AnimatedIcon.png" type="image/png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
/* Theme Variables */
body.red {
--navbar-bg: linear-gradient(135deg, rgba(180, 14, 14, 0.95), rgba(140, 10, 10, 0.95));
--sidebar-bg: rgba(97, 14, 14, 0.9);
--popup-bg: rgba(180, 14, 14, 0.95);
--card-bg: rgba(180, 14, 14, 0.9);
--card-hover-bg: rgba(107, 10, 10, 0.95);
--table-bg: rgba(180, 14, 14, 0.85);
--btn-confirm-bg: #28a745;
--btn-cancel-bg: #dc3545;
--btn-close-bg: #0078d7;
--accent-color: #ff6b6b;
}
body.green {
--navbar-bg: linear-gradient(135deg, rgba(0, 150, 12, 1), rgba(0, 120, 10, 1));
--sidebar-bg: rgba(0, 150, 12, 0.4);
--popup-bg: rgba(0, 150, 12, 1);
--card-bg: rgba(0, 150, 12, 0.9);
--card-hover-bg: rgba(0, 110, 10, 1);
--table-bg: rgba(0, 150, 12, 0.9);
--btn-confirm-bg: rgba(0, 120, 10, 1);
--btn-cancel-bg: #125210;
--btn-close-bg: #006400;
--accent-color: #4ade80;
}
body.purple {
--navbar-bg: linear-gradient(135deg, rgba(87, 27, 148, 0.95), rgba(63, 20, 110, 0.95));
--sidebar-bg: rgba(87, 27, 148, 0.4);
--popup-bg: rgba(87, 27, 148, 0.95);
--card-bg: rgba(87, 27, 148, 0.9);
--card-hover-bg: rgba(63, 20, 110, 0.95);
--table-bg: rgba(87, 27, 148, 0.85);
--btn-confirm-bg: #7e57c2;
--btn-cancel-bg: #ab47bc;
--btn-close-bg: #512da8;
--accent-color: #a78bfa;
}
body.dark {
--navbar-bg: linear-gradient(135deg, rgba(30, 30, 30, 0.98), rgba(20, 20, 20, 0.98));
--sidebar-bg: rgba(35, 35, 35, 0.95);
--popup-bg: rgba(40, 40, 40, 0.98);
--card-bg: rgba(35, 35, 35, 0.95);
--card-hover-bg: rgba(55, 55, 55, 0.95);
--table-bg: rgba(40, 40, 40, 0.95);
--btn-confirm-bg: #2a2a2a;
--btn-cancel-bg: #660000;
--btn-close-bg: #333333;
--accent-color: #60a5fa;
}
body.blue {
--navbar-bg: linear-gradient(135deg, rgba(30, 70, 180, 0.98), rgba(20, 50, 130, 0.98));
--sidebar-bg: rgba(30, 70, 180, 0.4);
--popup-bg: rgba(30, 70, 180, 0.98);
--card-bg: rgba(30, 70, 180, 0.9);
--card-hover-bg: rgba(20, 50, 130, 0.95);
--table-bg: rgba(30, 70, 180, 0.9);
--btn-confirm-bg: #1e46b4;
--btn-cancel-bg: #123070;
--btn-close-bg: #0b2448;
--accent-color: #60a5fa;
}
body.pink {
--navbar-bg: linear-gradient(135deg, rgba(200, 50, 120, 0.95), rgba(150, 30, 90, 0.95));
--sidebar-bg: rgba(200, 100, 130, 0.4);
--popup-bg: rgba(200, 50, 120, 0.95);
--card-bg: rgba(200, 50, 120, 0.9);
--card-hover-bg: rgba(150, 30, 90, 0.95);
--table-bg: rgba(200, 50, 120, 0.85);
--btn-confirm-bg: #c83278;
--btn-cancel-bg: #a02060;
--btn-close-bg: #801850;
--accent-color: #f472b6;
}
body.orange {
--navbar-bg: linear-gradient(135deg, rgba(255, 115, 0, 0.95), rgba(200, 90, 0, 0.95));
--sidebar-bg: rgba(255, 160, 60, 0.4);
--popup-bg: rgba(255, 115, 0, 0.95);
--card-bg: rgba(255, 115, 0, 0.9);
--card-hover-bg: rgba(200, 90, 0, 0.95);
--table-bg: rgba(255, 115, 0, 0.85);
--btn-confirm-bg: #ff9800;
--btn-cancel-bg: #e65100;
--btn-close-bg: #bf360c;
--accent-color: #fb923c;
}
body.yellow {
--navbar-bg: linear-gradient(135deg, rgba(204, 153, 0, 0.95), rgba(160, 120, 0, 0.95));
--sidebar-bg: rgba(204, 153, 0, 0.4);
--popup-bg: rgba(204, 153, 0, 0.95);
--card-bg: rgba(204, 153, 0, 0.9);
--card-hover-bg: rgba(160, 120, 0, 0.95);
--table-bg: rgba(204, 153, 0, 0.85);
--btn-confirm-bg: #cc9900;
--btn-cancel-bg: #b8860b;
--btn-close-bg: #8b7500;
--accent-color: #fbbf24;
}
/* Campus branding: matches the login page + the UIGradient recolor patch. Purple/
magenta/orange/gold sunset ramp - same 4 stops sampled from the admin panel's
Background.png. ALL colour values are solid rgba() (never gradients!) because
the page uses BOTH `background:` and `background-color:` against these vars and
`background-color:` silently drops gradients, which would make cards, navbar
buttons and the user chip vanish. Gradients live in the body background only. */
body.campus {
--navbar-bg: rgba(46, 20, 60, 0.72); /* deep ink for top bar */
--sidebar-bg: rgba(46, 20, 60, 0.58);
--popup-bg: rgba(30, 14, 46, 0.95);
--card-bg: rgba(121, 27, 141, 0.55); /* purple, translucent */
--card-hover-bg: rgba(191, 41, 102, 0.75); /* magenta, opaque-ish */
--table-bg: rgba(30, 14, 46, 0.72);
--btn-confirm-bg: #F95D24; /* Campus orange */
--btn-cancel-bg: #791B8D; /* Campus purple */
--btn-close-bg: #4a1361;
--accent-color: #FA9912; /* Campus gold */
/* The actual sunset painted directly on body (fixed so scrolling content
floats above it). Kept HERE, not on the vars, so it doesn't collide with
any element that does `background: var(--something)`. */
background: linear-gradient(135deg, #791B8D 0%, #BF2966 40%, #F95D24 75%, #FA9912 100%) fixed;
}
/* AnimatedIcon watermark drifting behind every admin page. z-index: -1 pushes it
behind ALL sibling content without needing any rule on children (which would
risk breaking layouts that rely on default `position: static`). */
body.campus::before {
content: "";
position: fixed;
inset: -40vmax;
background-image: url('/admin-assets/AnimatedIcon.png');
background-repeat: repeat;
background-size: 150px 150px;
transform: rotate(-8deg);
opacity: 0.08;
pointer-events: none;
z-index: -1;
animation: campusDrift 60s linear infinite;
}
@keyframes campusDrift {
from { background-position: 0 0; }
to { background-position: 300px 300px; }
}
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
--navbar-bg: linear-gradient(135deg, rgba(180, 14, 14, 0.95), rgba(140, 10, 10, 0.95));
--sidebar-bg: rgba(97, 14, 14, 0.9);
--popup-bg: rgba(180, 14, 14, 0.95);
--card-bg: rgba(180, 14, 14, 0.9);
--card-hover-bg: rgba(107, 10, 10, 0.95);
--table-bg: rgba(180, 14, 14, 0.85);
--btn-confirm-bg: #28a745;
--btn-cancel-bg: #dc3545;
--btn-close-bg: #0078d7;
--accent-color: #ff6b6b;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #000000;
color: white;
min-height: 100vh;
background-size: cover;
background-position: center center;
}
/* Background Layer */
#background-blur-layer {
position: fixed;
top: -10px;
left: -10px;
width: calc(100vw + 20px);
height: calc(100vh + 20px);
background-size: cover;
background-position: center;
background-attachment: fixed;
filter: blur(2px);
z-index: -1;
transition: background-image 0.3s ease;
}
/* Space Background */
.space-background {
position: fixed;
top: 0;
left: 0;
z-index: -100;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
visibility: hidden;
}
.star {
position: absolute;
width: 3px;
height: 3px;
background: #fff;
border-radius: 50%;
animation: twinkle 2s infinite ease-in-out;
}
.star:nth-child(odd) {
animation-duration: 4s;
}
@keyframes twinkle {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
/* Navbar */
.navbar {
display: flex;
align-items: center;
background: var(--navbar-bg);
position: fixed;
width: 100%;
top: 0;
left: 0;
z-index: 1000;
gap: 20px;
padding: 12px 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.navbar h1 {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.tabs {
display: flex;
gap: 8px;
flex: 1;
}
.navbar button {
background-color: var(--card-hover-bg);
color: white;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 8px;
border: none;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
}
.navbar button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
opacity: 0.9;
}
.navbar button.active {
background: var(--accent-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
#you-button {
background-color: var(--card-hover-bg);
border-radius: 8px;
padding: 8px 16px;
transition: all 0.2s ease;
}
#you-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* Content Area */
.content {
display: none;
opacity: 0;
text-align: center;
transition: opacity 0.3s ease-in-out;
margin-left: 40px;
margin-right: 40px;
padding: 100px 20px 40px;
}
.content.active {
display: block;
opacity: 1;
}
.content h2 {
font-size: 32px;
font-weight: 600;
margin-bottom: 24px;
color: var(--accent-color);
}
.content p {
font-size: 16px;
color: #ddd;
line-height: 1.6;
}
/* Dashboard Cards */
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
margin-bottom: 40px;
justify-content: center;
}
.card,
.card_2 {
background: var(--card-bg);
padding: 24px;
border-radius: 16px;
text-align: center;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.card:hover,
.card_2:hover {
background: var(--card-hover-bg);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.card h3 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 600;
color: var(--accent-color);
}
.card p {
margin: 8px 0;
font-size: 18px;
color: #fff;
font-weight: 500;
}
.card button {
margin-top: 16px;
background: var(--btn-close-bg);
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.card button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Tables */
.table {
color: rgba(255, 255, 255, 0.95);
background: var(--table-bg);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.table thead {
background: rgba(0, 0, 0, 0.3);
}
.table th {
padding: 16px;
font-weight: 600;
border: none;
}
.table td {
padding: 12px 16px;
vertical-align: middle;
border: none;
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(255, 255, 255, 0.05);
}
.table button {
margin: 4px;
padding: 8px 16px;
border-radius: 6px;
transition: all 0.2s ease;
}
.table button:hover {
transform: scale(1.05);
}
/* Profile Images */
.profile-img,
.profile-img2 {
border-radius: 12px;
object-fit: cover;
transition: transform 0.3s ease;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.profile-img {
width: 80px;
height: 80px;
}
.profile-img2 {
width: 150px;
height: 150px;
}
.profile-img:hover,
.profile-img2:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
/* Popups */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
visibility: hidden;
opacity: 0;
transition: all 0.3s ease;
backdrop-filter: blur(8px);
z-index: 2000;
}
.popup-overlay.active {
visibility: visible;
opacity: 1;
}
.popup {
background: var(--popup-bg);
border-radius: 16px;
padding: 32px;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
text-align: center;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.popup img {
max-width: 120px;
margin-bottom: 20px;
border-radius: 12px;
}
.popup h2 {
margin: 0 0 16px;
font-size: 24px;
color: var(--accent-color);
font-weight: 600;
}
.popup p {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 20px;
line-height: 1.5;
}
/* Player Popup */
.player-popup {
background: var(--popup-bg);
max-width: 600px;
width: 550px;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease;
}
.popup-header {
display: flex !important;
flex-direction: row;
align-items: center;
gap: 20px;
margin-bottom: 20px;
}
#player-popup-icon {
display: block;
max-width: 40%;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
#player-popup-title {
font-size: 24px;
font-weight: 600;
color: var(--accent-color);
margin: 0;
}
#player-popup-text {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin: 4px 0 0;
}
#player-popup-banstatus {
margin: 8px 0 0;
padding: 8px 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
}
#player-popup-bio {
margin-top: 16px;
padding: 16px;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
line-height: 1.6;
white-space: pre-line;
}
#popup-buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
/* Buttons */
.confirm-btn,
.cancel-btn,
.close-btn {
background: #300c77;
color: #fff;
border: none;
padding: 12px 24px;
font-size: 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.confirm-btn {
background: var(--btn-confirm-bg);
}
.confirm-btn:hover {
background: #218838;
transform: scale(1.05);
}
.cancel-btn {
background: var(--btn-cancel-bg);
}
.cancel-btn:hover {
opacity: 0.9;
transform: scale(1.05);
}
.close-btn {
background: var(--btn-close-bg);
}
.close-btn:hover {
background: #005a9e;
transform: scale(1.05);
}
/* Forms */
.themed-form-container {
background-color: var(--table-bg);
color: white;
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.themed-form-container form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.themed-form-container input,
.themed-form-container select {
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.3);
color: white;
font-size: 14px;
transition: all 0.2s ease;
}
.themed-form-container input:focus,
.themed-form-container select:focus {
outline: none;
border-color: var(--accent-color);
background: rgba(0, 0, 0, 0.4);
}
.themed-form-container button {
padding: 10px 20px;
border-radius: 8px;
font-weight: 500;
transition: all 0.2s ease;
}
.themed-form-container button:hover {
transform: scale(1.05);
}
.themed-form-container label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.input-block {
display: block;
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
margin-bottom: 16px;
background: rgba(0, 0, 0, 0.3);
color: white;
transition: all 0.2s ease;
}
.input-block:focus {
outline: none;
border-color: var(--accent-color);
background: rgba(0, 0, 0, 0.4);
}
/* Dropdown Container */
.dropdown-container {
margin: 20px 0;
padding: 20px;
background-color: rgba(51, 51, 51, 0.8);
border-radius: 12px;
width: 280px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.dropdown-container label {
color: #fff;
font-size: 16px;
display: block;
margin-bottom: 12px;
font-weight: 500;
}
#background-selector,
#theme-selector {
width: 100%;
padding: 12px 14px;
font-size: 14px;
color: #fff;
background-color: rgba(17, 16, 16, 0.9);
border: 1px solid rgba(119, 119, 119, 0.5);
border-radius: 8px;
outline: none;
cursor: pointer;
transition: all 0.2s ease;
}
#background-selector:focus,
#theme-selector:focus {
border-color: var(--accent-color);
box-shadow: 0 0 8px rgba(0, 188, 212, 0.5);
}
#background-selector option,
#theme-selector option {
background-color: #222;
color: #fff;
padding: 10px;
}
/* Toggle Switch */
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 9px;
left: 12px;
right: 0;
bottom: 0;
background-color: #fff;
transition: 0.4s;
height: 16px;
width: 42px;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: -4px;
bottom: -4px;
background-color: rgb(241, 110, 70);
transition: 0.4s;
}
input:checked+.slider {
background-color: rgb(135, 69, 49);
}
input+.slider {
background-color: #616161;
}
input:checked+.slider:before {
background-color: rgb(241, 110, 70);
transform: translateX(26px);
}
input+.slider:before {
background-color: rgb(224, 224, 224);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
/* Spinning Animation */
.spinning {
animation: spin 3s linear infinite;
width: 120px;
height: 100px;
object-fit: contain;
margin: 0 auto;
display: block;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ban-status {
padding: 10px 14px;
border-radius: 10px;
font-size: 14px;
line-height: 1.4;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
/* NOT BANNED */
.ban-status.ok {
background: rgba(40, 167, 69, 0.15);
border: 1px solid rgba(40, 167, 69, 0.4);
color: #9be7b2;
}
/* BANNED */
.ban-status.bad {
background: rgba(220, 53, 69, 0.18);
border: 1px solid rgba(220, 53, 69, 0.45);
color: #ff9aa2;
}
.ban-status i {
font-size: 16px;
}
/* Mobile Support */
body.mobile-support {
font-size: 14px;
overflow-x: hidden;
}
body.mobile-support .navbar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
padding: 10px;
}
body.mobile-support .tabs {
flex-wrap: wrap;
justify-content: center;
gap: 8px;
width: 100%;
}
body.mobile-support .card,
body.mobile-support .card_2 {
width: 90% !important;
max-width: 100%;
font-size: 14px;
margin: 10px auto;
}
body.mobile-support .content {
margin-left: 0;
padding: 20px;
}
body.mobile-support .popup {
max-width: 95%;
padding: 15px;
font-size: 14px;
}
body.mobile-support .dashboard {
grid-template-columns: 1fr;
}
/* Utility Classes */
.log-item {
color: #ffffff;
max-width: 100%;
width: 100%;
border-radius: 8px;
background-color: var(--table-bg);
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid rgba(204, 204, 204, 0.2);
margin-left: 0;
padding-left: 12px;
transition: background 0.2s ease;
}
.list-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.list-item:last-child {
border-bottom: none;
}
/* Home Image */
#home-image {
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
/* Responsive Images */
img {
max-width: 100%;
height: auto;
}
.spacer {
flex: 1;
}
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
max-width: 900px;
margin: 0 auto 48px;
}
.stat-card {
background: rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 16px 18px;
text-align: center;
backdrop-filter: blur(6px);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
}
.stat-label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 6px;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 1.1rem;
font-weight: 600;
color: #fff;
}
.player-platforms {
margin-top: 16px;
padding: 12px;
background: rgba(0, 0, 0, 0.25);
border-radius: 12px;
}
.popup-section-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--accent-color);
}
.platform-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
margin-right: 8px;
margin-bottom: 6px;
white-space: nowrap;
}
.platform-badge.steam {
background: linear-gradient(135deg, #171a21, #2a475e);
color: #fff;
}
.platform-badge.muted {
background: rgba(255, 255, 255, 0.1);
color: #aaa;
}
.platform-badge.unknown {
background: #555;
color: #fff;
}
.ban-form {
display: flex;
flex-direction: column;
gap: 14px;
}
/* Labels */
.ban-form label {
font-size: 13px;
font-weight: 600;
color: #ff6b6b;
letter-spacing: 0.04em;
}
/* Inputs & Select */
.ban-form input,
.ban-form select {
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.15);
color: white;
padding: 10px 12px;
font-size: 14px;
border-radius: 8px;
transition: all 0.2s ease;
}
/* Focus */
.ban-form input:focus,
.ban-form select:focus {
outline: none;
border-color: #ff6b6b;
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.25);
background: rgba(0, 0, 0, 0.45);
}
/* Duration Grid */
.ban-duration {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.ban-duration input {
text-align: center;
font-weight: 500;
}
/* Remove ugly number arrows */
.ban-duration input::-webkit-outer-spin-button,
.ban-duration input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.ban-duration input {
-moz-appearance: textfield;
}
/* Category Select */
#banCategory {
cursor: pointer;
}
.ban-form::after {
font-size: 12px;
color: rgba(255, 107, 107, 0.75);
margin-top: 4px;
}
.ban-time {
display: flex;
gap: 10px;
}
.ban-time input,
.ban-time select {
flex: 1;
padding: 10px;
font-size: 14px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.04);
color: #fff;
}
.ban-preview {
margin-top: 6px;
font-size: 13px;
color: #9aa0a6;
}
.ban-appeal-toggle {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Appeal text */
.appeal-text {
display: flex;
flex-direction: column;
font-size: 13px;
color: #fff;
}
.appeal-text span {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
/* Reuse your existing switch styling, but tighten it */
.ban-appeal-toggle .switch {
width: 52px;
height: 28px;
}
.ban-appeal-toggle .slider {
height: 18px;
width: 40px;
}
.ban-appeal-toggle .slider:before {
height: 22px;
width: 22px;
}
/* Role toggle buttons in player rows. Inactive = neutral gray; active = Campus purple. */
.btn-role {
background: rgba(255, 255, 255, 0.08);
color: #d9c9ff;
border: 1px solid rgba(155, 92, 255, 0.35);
padding: 4px 10px;
margin-left: 4px;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-role:hover { background: rgba(155, 92, 255, 0.25); }
.btn-role.role-on { background: #9b5cff; color: #fff; border-color: #b784ff; box-shadow: 0 0 6px rgba(155, 92, 255, 0.5); }
.btn-role.role-on:hover { background: #b784ff; }
</style>
<!-- ═══════════════════════════════════════════════════════════════════════════
Campus website skin: reskins the admin panel to match the main site's design
(Inter, flat off-white/dark surfaces, purple + warm gradient accents) and adds
full mobile responsiveness. Loaded LAST so it wins the cascade over the legacy
theme CSS above; leaves all structure/markup/JS untouched.
═══════════════════════════════════════════════════════════════════════════ -->
<style id="campus-website-skin">
:root{
--primary:#7c3aed; --primary-dark:#6d28d9; --primary-soft:#f0ebfe;
--page:#f6f7f9; --bg:#ffffff; --card:#ffffff; --panel:#f3f4f6;
--line:#e6e7eb; --text:#111827; --muted:#6b7280;
--ok:#15803d; --danger:#dc2626; --warn:#b45309;
--shadow:0 1px 2px rgba(16,24,40,.05), 0 1px 3px rgba(16,24,40,.06);
}
@media (prefers-color-scheme: dark){
:root{
--primary:#a78bfa; --primary-dark:#8b5cf6; --primary-soft:rgba(167,139,250,.14);
--page:#0f1117; --bg:#151823; --card:#171a24; --panel:#1e2230;
--line:#2a2f3d; --text:#e7e9ee; --muted:#98a1b3;
--ok:#4ade80; --danger:#f87171; --warn:#fbbf24;
--shadow:0 1px 2px rgba(0,0,0,.4);
}
}
/* base — kill the legacy sunset gradient + watermark + blur/space layers */
body, body.campus, body.red, body.green, body.purple, body.dark{
background:var(--page) !important; color:var(--text) !important;
font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif !important;
letter-spacing:-.006em; font-size:14px;
}
body.campus::before, #background-blur-layer, .space-background{ display:none !important; }
#home-image{ display:none !important; } /* placeholder hero avatar — clutter */
* { -webkit-font-smoothing:antialiased; }
/* ── top bar: brand · tabs · you ── */
.navbar{
background:var(--card) !important; border-bottom:1px solid var(--line) !important;
box-shadow:none !important; gap:16px !important; padding:0 20px !important; height:58px;
}
.admin-brand{ order:0; display:flex; align-items:center; gap:10px; font-weight:800; font-size:1rem;
color:var(--text); white-space:nowrap; letter-spacing:-.02em; }
.admin-brand .admin-logo{ height:26px; width:auto; display:block; }
@media (prefers-color-scheme: dark){ .admin-brand .admin-logo{ filter:brightness(0) invert(1); } }
.tabs{ order:1; gap:2px !important; }
.spacer{ order:2; }
#you-button-container{ order:3; }
#you-button, #you-button.btn, .navbar #you-button{
background:transparent !important; color:var(--text) !important; border:1px solid var(--line) !important;
border-radius:999px !important; font-weight:600 !important; padding:5px 12px 5px 6px !important;
}
#you-button:hover{ transform:none !important; border-color:var(--primary) !important; box-shadow:none !important; }
#you-button .profile-img2{ width:26px !important; height:26px !important; }
.navbar button, .tabs .tab{
background:transparent !important; color:var(--muted) !important; border:none !important;
border-radius:8px !important; font-weight:600 !important; font-size:.85rem !important;
padding:8px 12px !important; transition:color .12s, background .12s !important;
}
.navbar button:hover, .tabs .tab:hover{
transform:none !important; box-shadow:none !important; opacity:1 !important;
color:var(--text) !important; background:var(--panel) !important; border:none !important;
}
.navbar button.active, .tabs .tab.active{
background:var(--primary-soft) !important; color:var(--primary) !important; border:none !important;
box-shadow:none !important; font-weight:700 !important;
}
/* ── content: a real left-aligned, contained page ── */
.content{ text-align:left !important; max-width:none !important; width:100% !important; margin:0 !important;
padding:84px 32px 56px !important; box-sizing:border-box; }
.content h2{ color:var(--text) !important; font-weight:800 !important; font-size:1.6rem !important;
letter-spacing:-.02em; margin:0 0 4px !important; text-align:left !important; }
.content > h2 + .stat-cards, .content > h2{ margin-top:0; }
.content h3{ text-align:left !important; }
.content p{ color:var(--muted) !important; text-align:left !important; }
.dashboard-header{ margin-bottom:26px; }
section.dashboard, .dashboard{ justify-content:start !important; }
/* section labels that were centered h2s ("Quick Links") */
.content h2, .content h3, .section-title{ text-align:left !important; }
/* ── metric / stat cards ── */
.stat-cards{ max-width:none !important; margin:16px 0 30px !important; }
.stat-card, .card, .card_2{
background:var(--card) !important; border:1px solid var(--line) !important; color:var(--text) !important;
box-shadow:var(--shadow) !important; backdrop-filter:none !important; text-align:left !important;
border-radius:12px !important;
}
.stat-card{ padding:16px 18px !important; }
.stat-label, .stat-card .stat-label{ display:block; color:var(--muted) !important; font-size:.72rem !important;
font-weight:700 !important; text-transform:uppercase; letter-spacing:.05em; }
.stat-value, .stat-card .stat-value{ display:block; color:var(--text) !important; font-size:1.5rem !important;
font-weight:800 !important; margin-top:4px; letter-spacing:-.02em; }
.card:hover, .card_2:hover{ background:var(--card) !important; border-color:var(--line) !important;
transform:none !important; box-shadow:0 4px 14px rgba(16,24,40,.08) !important; }
.card h3, .card_2 h3{ color:var(--text) !important; font-size:1rem !important; font-weight:700 !important;
margin:0 0 6px !important; text-align:left !important; }
.card p, .card_2 p{ color:var(--muted) !important; font-size:1.35rem !important; font-weight:800 !important;
letter-spacing:-.02em; margin:0 0 14px !important; text-align:left !important; }
.card p:empty{ display:none; }
/* ── tables ── */
.table, table.table{ background:var(--card) !important; color:var(--text) !important; border:1px solid var(--line) !important;
border-radius:12px; box-shadow:var(--shadow) !important; overflow:hidden; text-align:left; }
.table thead{ background:var(--panel) !important; }
.table th{ color:var(--muted) !important; text-transform:uppercase; letter-spacing:.04em; font-size:.7rem !important;
font-weight:700 !important; text-align:left !important; padding:12px 14px !important; }
.table td{ color:var(--text) !important; border-top:1px solid var(--line) !important; text-align:left !important;
padding:11px 14px !important; vertical-align:middle; }
.table-striped tbody tr:nth-of-type(odd){ background-color:rgba(128,128,128,.04) !important; }
.table tbody tr:hover{ background:var(--panel) !important; }
.list-item{ border-bottom:1px solid var(--line) !important; }
.list-item:hover{ background:var(--panel) !important; }
/* ── inputs ── */
input[type=text],input[type=number],input[type=password],input[type=search],input[type=email],
textarea, select, .form-control{
background:var(--bg) !important; color:var(--text) !important; border:1px solid var(--line) !important;
border-radius:8px !important; font-family:inherit !important;
}
input::placeholder, textarea::placeholder{ color:var(--muted) !important; }
input:focus, textarea:focus, select:focus, .form-control:focus{ outline:none !important;
border-color:var(--primary) !important; box-shadow:0 0 0 3px var(--primary-soft) !important; }
/* ── buttons: one restrained solid accent, no rainbow ── */
.btn{ border-radius:8px !important; font-weight:600 !important; font-family:inherit !important;
font-size:.85rem !important; padding:8px 14px !important; transition:filter .12s, background .12s, border-color .12s !important;
box-shadow:none !important; }
.btn:hover{ transform:none !important; }
.btn-info, .btn-primary{ background:var(--primary) !important; border:1px solid var(--primary) !important; color:#fff !important; }
.btn-info:hover, .btn-primary:hover{ background:var(--primary-dark) !important; border-color:var(--primary-dark) !important; }
.btn-success{ background:var(--ok) !important; border:1px solid var(--ok) !important; color:#fff !important; }
.btn-danger{ background:var(--danger) !important; border:1px solid var(--danger) !important; color:#fff !important; }
.btn-secondary, .btn-light, .btn-default{ background:var(--panel) !important; border:1px solid var(--line) !important; color:var(--text) !important; }
/* "View …" navigation buttons were orange btn-warning — make them a neutral secondary that matches the site */
.btn-warning, .btn-room-view, .btn-edit{ background:var(--panel) !important; border:1px solid var(--line) !important; color:var(--text) !important; }
.btn-warning:hover, .btn-room-view:hover, .btn-edit:hover{ border-color:var(--primary) !important; color:var(--primary) !important; background:var(--primary-soft) !important; }
/* action cells: a tidy wrapping button cluster so nothing overlaps */
.table td .btn{ margin:3px 4px 3px 0 !important; vertical-align:top; }
.cell-actions{ display:flex; flex-wrap:wrap; gap:6px; max-width:300px; align-items:flex-start; }
.cell-actions .btn{ margin:0 !important; white-space:nowrap; }
/* card actions → quiet ghost buttons, not a wall of filled blocks */
.card .btn, .card_2 .btn, .card button, .card_2 button{
background:transparent !important; border:1px solid var(--line) !important; color:var(--text) !important;
width:auto !important; padding:7px 13px !important; font-weight:600 !important; margin-top:0 !important; }
.card .btn:hover, .card_2 .btn:hover, .card button:hover, .card_2 button:hover{
border-color:var(--primary) !important; color:var(--primary) !important; background:var(--primary-soft) !important; }
/* role toggles (Dev/Mod/Support/Influencer…) */
.btn-role{ background:transparent !important; border:1px solid var(--line) !important; color:var(--text) !important; border-radius:8px !important; font-weight:600 !important; }
.btn-role:hover{ background:var(--primary-soft) !important; border-color:var(--primary) !important; color:var(--primary) !important; }
.btn-role.role-on{ background:var(--primary) !important; border-color:var(--primary) !important; color:#fff !important; box-shadow:none !important; }
.btn-role.role-on:hover{ background:var(--primary-dark) !important; color:#fff !important; }
/* popups / modals */
.popup, .modal-content{ background:var(--card) !important; color:var(--text) !important; border:1px solid var(--line) !important; border-radius:14px !important; box-shadow:0 20px 60px rgba(0,0,0,.28) !important; }
.popup h2, .modal-content h2{ text-align:left; }
/* ── responsive / mobile ── */
@media (max-width:900px){
.navbar{ position:sticky !important; top:0; flex-wrap:nowrap !important; padding:0 10px !important; gap:8px !important; }
.navbar .spacer{ display:none !important; }
.admin-brand span{ display:none; } /* logo mark only on small screens */
#you-button-container{ flex:0 0 auto; }
.tabs{ overflow-x:auto !important; flex-wrap:nowrap !important; -webkit-overflow-scrolling:touch; scrollbar-width:none; }
.tabs::-webkit-scrollbar{ display:none; }
.tabs .tab, .navbar button{ flex:0 0 auto !important; white-space:nowrap !important; }
.content{ margin:0 auto !important; padding:16px 14px 60px !important; }
.content h2{ font-size:1.35rem !important; }
.dashboard, .stat-cards{ grid-template-columns:1fr !important; }
.table, table.table{ display:block !important; overflow-x:auto !important; white-space:nowrap; }
.popup, .modal-content{ max-width:95vw !important; }
}
@media (max-width:480px){
#you-button{ font-size:0 !important; padding:4px !important; }
#you-button .profile-img2{ margin-right:0 !important; }
.tabs .tab{ font-size:.82rem !important; padding:8px 10px !important; }
}
</style>
</head>
<body class="campus">
<div id="background-blur-layer"></div>
<div class="space-background" id="space-background"></div>
<div class="popup-overlay" id="alert-popup">
<div class="popup">
<img id="popup-icon" src="" alt="Icon">
<h2 id="popup-title">null</h2>
<p id="popup-text">null</p>
<button class="close-btn" onclick="closePopup('alert-popup')">Close</button>
</div>
</div>
<div id="ban-modal" class="popup-overlay">
<div class="popup" style="max-width: 520px;">
<h2 id="banTitle">Ban Player</h2>
<img id="accountimage1"
style="margin: 0 auto 12px; width:128px; height:128px; border-radius:10px; display:none;">
<div id="banStatus" style="margin-bottom:10px; display:none;"></div>
<input type="hidden" id="playerid">
<div class="ban-form">
<label>Ban Reason</label>
<input type="text" id="banmsg" placeholder="Enter reason for ban" required>
<label>Duration</label>
<div class="ban-time">
<input type="number"
id="banAmount"
min="1"
placeholder="Amount">
<select id="banUnit">
<option value="seconds">Seconds</option>
<option value="minutes" selected>Minutes</option>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="years">Years</option>
<option value="permanent">Permanent</option>
</select>
</div>
<label>Category</label>
<select id="banCategory">
<option value="-1">Moderator</option>
<option value="2">Harassment</option>
<option value="3">Cheating</option>
<option value="5">AFK</option>
<option value="6">Misc</option>
<option value="7">Underage</option>
<option value="101">Sexual Behaviour</option>
<option value="102">Discrimination</option>
<option value="103">Trolling</option>
</select>
<div class="ban-appeal-toggle">
<label class="switch">
<input type="checkbox" id="includeAppeal">
<span class="slider round"></span>
</label>
<div class="appeal-text">
<strong>Add appeal message</strong>
<span>"To appeal this ban, Make a ticket in https://discord.gg/xH8MMwK5Bk."</span>
</div>
</div>
<div class="ban-appeal-toggle">
<label class="switch">
<input type="checkbox" id="banDevice">
<span class="slider round"></span>
</label>
<div class="appeal-text">
<strong>Also ban their device</strong>
<span>Blocks every account on that device, checked at login.</span>
</div>
</div>
<div class="ban-appeal-toggle">
<label class="switch">
<input type="checkbox" id="banIp">
<span class="slider round"></span>
</label>
<div class="appeal-text">
<strong>Also ban their IP</strong>
<span>Blocks every known address for this account. May affect others sharing it.</span>
</div>
</div>
</div>
<div style="display:flex; gap:10px; justify-content:flex-end;">
<button class="btn btn-secondary" onclick="closeBanModal()">Cancel</button>
<button class="btn btn-danger" onclick="ban()">Ban</button>
</div>
</div>
</div>
<div class="popup-overlay" id="confirm-popup">
<div class="popup">
<h2 id="confirm-title">Confirm</h2>
<p id="confirm-text">Nice confirm.</p>
<div class="button-container">
<button class="confirm-btn" id="confirm-action">Confirm</button>
<button class="cancel-btn" onclick="closePopup('confirm-popup')">Cancel</button>
</div>
</div>
</div>
<div class="popup-overlay" id="player-popup">
<div class="player-popup">
<!-- HEADER -->
<div class="popup-header">
<img id="player-popup-icon" src="" alt="Icon">
<div class="popup-header-text">
<h2 id="player-popup-title">null</h2>
<p id="player-popup-text">null</p>
<div id="banStatus" style="margin-bottom:10px; display:none;"></div>
<div id="player-popup-platforms" class="player-platforms"></div>
</div>
</div>
<div class="player-section">
<h4 class="section-title">Bio</h4>
<p id="player-popup-bio">This player has not written a bio yet.</p>
</div>
<!-- BUTTONS -->
<div id="popup-buttons"></div>
</div>
</div>
<div class="navbar">
<div class="admin-brand"><img class="admin-logo" src="/admin-assets/AnimatedIcon.png" alt="Campus"><span>Campus&nbsp;Admin</span></div>
<div id="you-button-container" class="d-flex align-items-center">
<button id="you-button" class="btn btn-info" onclick="viewProfile()">
<img id="profile-image" src="/img/DefaultImg.png" alt="Profile" class="profile-img2"
style="width: 30px; height: 30px; margin-right: 10px; border-radius: 50%;">
YOU
</button>
</div>
<div class="spacer"></div>
<div class="tabs">
<button class="tab active" data-tab="home"><i class="fas fa-home"></i> Dashboard</button>
<button class="tab" data-tab="Players"><i class="fas fa-user"></i> Players</button>
<button class="tab" data-tab="Rooms"><i class="fas fa-folder-open"></i> Rooms</button>
<button class="tab" data-tab="Featured"><i class="fa-solid fa-star"></i> Featured</button>
<button class="tab" data-tab="Server"><i class="fa fa-server"></i> Server</button>
<button class="tab" data-tab="Coach"><i class="fa-solid fa-puzzle-piece"></i> Coach</button>
<button class="tab" data-tab="EditPlayer"><i class="fa-solid fa-user-pen"></i> Edit Player</button>
<button class="tab" data-tab="Events"><i class="fa-solid fa-calendar-day"></i> Events</button>
<button class="tab" data-tab="Photos"><i class="fa-solid fa-camera"></i> Photos</button>
<button class="tab" data-tab="Comments"><i class="fa-solid fa-comment-dots"></i> Comments</button>
<button class="tab" data-tab="Reports"><i class="fa-solid fa-flag"></i> Reports <span id="reportsBadge" style="display:none; background:#c0392b; color:#fff; border-radius:10px; padding:1px 7px; font-size:12px; margin-left:4px;"></span></button>
<button class="tab" data-tab="Staff"><i class="fa-solid fa-user-shield"></i> Staff</button>
<button class="tab" data-tab="Gifts"><i class="fa-solid fa-gift"></i> Gifts</button>
<button class="tab" data-tab="Economy"><i class="fa-solid fa-coins"></i> Economy</button>
<button class="tab" data-tab="Database"><i class="fa-solid fa-database"></i> Database</button>
<button class="tab" data-tab="Log"><i class="fa-solid fa-terminal"></i> Log</button>
<button class="tab" data-tab="Settings"><i class="fa-solid fa-gear"></i> Settings</button>
</div>
</div>
<style>
.log-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }
.log-toolbar input[type="text"] { flex: 1 1 320px; min-width: 200px; padding: 9px 12px; border-radius: 8px;
border: 1px solid rgba(255,255,255,.18); background: rgba(0,0,0,.25); color: #eee; font-family: inherit; }
.log-toolbar select { padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,.18);
background: rgba(0,0,0,.25); color: #eee; }
.log-toolbar label { display: inline-flex; align-items: center; gap: 6px; opacity: .85; font-size: 13px; }
.log-auto { user-select: none; }
.log-console {
height: 60vh; min-height: 320px; overflow: auto; margin: 0;
background: #14141b; color: #d7dae0; border: 1px solid rgba(255,255,255,.12); border-radius: 10px;
padding: 14px 16px; white-space: pre; tab-size: 4;
font-family: ui-monospace, "Cascadia Code", "Consolas", monospace; font-size: 12.5px; line-height: 1.5;
}
/* Colourise the request-log rows so a failure jumps out. */
.log-console .l-err { color: #ff6b6b; } /* 4xx/5xx */
.log-console .l-ok { color: #63c37b; } /* 2xx */
.log-console .l-dim { color: #7f8694; } /* everything else / timestamps */
/* Force-join card + room-status cell */
.fj-card { background: rgba(0,0,0,.18); border: 1px solid rgba(255,255,255,.12); border-radius: 10px;
padding: 14px 16px; margin: 8px 0 18px; }
.fj-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }
.fj-row label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; opacity: .9; }
.fj-row input { padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,.18);
background: rgba(0,0,0,.25); color: #eee; min-width: 160px; }
.fj-arrow { opacity: .6; margin-bottom: 10px; }
.rt-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.rt-on { background: #63c37b; box-shadow: 0 0 6px #63c37b88; }
.rt-off { background: #6b6f78; }
.rt-priv { color: #f6a609; font-weight: 700; }
</style>
<!-- Live server request log (tails Data/APILog.txt via /admin/api/apilog). -->
<div class="content" id="Log">
<section class="dashboard-header">
<h2 id="log-title">Server Log</h2>
<p class="subtle">Live tail of every request the server handles. Use the filter to isolate one endpoint (e.g. <code>/api/</code>, a player id, or <code>404</code>).</p>
</section>
<div class="log-toolbar">
<input id="log-filter" type="text" placeholder="Filter (substring, e.g. /Accounts/ or 404)" />
<label>Lines
<select id="log-lines">
<option value="200">200</option>
<option value="500" selected>500</option>
<option value="1000">1000</option>
<option value="2000">2000</option>
<option value="5000">5000</option>
</select>
</label>
<label class="log-auto"><input id="log-auto" type="checkbox" checked /> Auto-refresh</label>
<button class="btn btn-info" onclick="refreshLog()"><i class="fa-solid fa-rotate"></i> Refresh</button>
<button class="btn" onclick="clearLogView()">Clear view</button>
<span id="log-status" class="subtle"></span>
</div>
<pre id="log-console" class="log-console">Loading log…</pre>
</div>
<div class="content active" id="home">
<!-- Dashboard Header / Hero -->
<section class="dashboard-header">
<img id="home-image" src="/img/DefaultImgGreen.png" alt="Dashboard Icon" class="profile-img2 themed-pfp" />
<h2 id="dashboard-title">Welcome to the Dashboard</h2>
<div class="stat-cards">
<div class="stat-card">
<span class="stat-label">Message of the Day</span>
<span class="stat-value" id="motd_msg">Loading…</span>
</div>
<div class="stat-card">
<span class="stat-label">RAM Usage</span>
<span class="stat-value" id="ram_mem">Loading…</span>
</div>
<div class="stat-card">
<span class="stat-label">Page Load Time</span>
<span class="stat-value" id="ms_ping">Loading…</span>
</div>
</div>
</section>
<!-- Main Dashboard Cards -->
<section class="dashboard">
<div class="card hidden">
<h3>Logs</h3>
<p id="logs">Check out logs</p>
<button class="btn btn-info" onclick="setup_html_show('Logs')">View Logs</button>
</div>
<div class="card">
<h3>Online Players</h3>
<p id="onlinePlayers">Loading...</p>
<button class="btn btn-info" onclick="viewOnlinePlayers()">View who is online</button>
</div>
<div class="card">
<h3>Total Rooms</h3>
<p id="totalRooms">Loading...</p>
<button class="btn btn-info" onclick="setup_html_show('Rooms')">View All Rooms</button>
</div>
<div class="card">
<h3>Total Players</h3>
<p id="totalPlayers">Loading...</p>
<button class="btn btn-info" onclick="setup_html_show('Players')">View All Players</button>
</div>
<div class="card hidden">
<h3>Total Clubs</h3>
<p id="clubs">Loading...</p>
<button class="btn btn-info" onclick="setup_html_show('Clubs')">View All Clubs</button>
</div>
<div class="card">
<h3>Total Inventions</h3>
<p id="inventions">Loading...</p>
<button class="btn btn-info" onclick="setup_html_show('Inventions')">View All Inventions</button>
</div>
</section>
<!-- Quick Links -->
<section class="quick-links">
<h2>Quick Links</h2>
<div class="dashboard" id="dashboard_quicklink"></div>
</section>
</div>
<div id="onlinePlayersPopup" class="popup">
<div class="popup-content">
<h2>Online Players</h2>
<ul id="onlinePlayersList"></ul>
<button class="btn btn-danger" onclick="closeonlineplayers()">Close</button>
</div>
</div>
<div class="content" id="Players">
<h2>Players <span id="players-live" style="font-size:.8rem; font-weight:400; color:var(--muted); margin-left:10px;"></span></h2>
<div class="themed-form-container">
<form id="playerSearchForm" style="display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start;">
<input type="text" placeholder="Player ID" id="searchPlayerId" style="width: 100px;">
<input type="text" placeholder="DisplayName" id="searchDisplayName" style="width: 130px;">
<input type="text" placeholder="Email (Bio partial)" id="searchEmail" style="width: 160px;">
<input type="text" placeholder="Min Level" id="searchMinLevel" style="width: 100px;">
<select id="searchJrState" style="width: 80px;">
<option value="N/A">N/A</option>
<option value="Jr">Jr</option>
<option value="Non-Jr">Non-Jr</option>
</select>
<input type="text" placeholder="IP" id="searchIp" style="width: 100px;">
<input type="text" placeholder="Device ID" id="searchDeviceId" style="width: 100px;">
<input type="text" placeholder="Platform ID" id="searchPlatformId" style="width: 100px;">
<select id="searchOrder" style="margin-top: 10px; width: 140px;">
<option value="NewestAccount">NewestAccount</option>
<option value="OldestAccount">OldestAccount</option>
</select>
<button type="button" class="btn btn-danger" style="margin-top: 10px;" onclick="searchPlayers()">
Search
players
</button>
<div style="display: flex; flex-direction: column; flex-wrap: wrap; margin-top: 10px; gap: 4px;">
<label>
<input type="checkbox" id="searchDisplayHistory"> Search DisplayName history [no partial
matches]?
</label>
<label><input type="checkbox" id="searchPartialMatches"> Find partial text matches?</label>
<label><input type="checkbox" id="searchJuniorsOnly"> Juniors Only</label>
<label>
<input type="checkbox" id="searchBannedOnly"> Banned (excluding timeouts & related player
bans)
</label>
<label><input type="checkbox" id="searchOnlineOnly"> Online</label>
<label><input type="checkbox" id="searchIgnoreNoBirthday"> Ignore No Birthday</label>
</div>
</form>
</div>
<!-- Force join: move one player into another player's current room instance. -->
<div class="fj-card">
<h4 class="section-title"><i class="fa-solid fa-people-arrows"></i> Force Join</h4>
<p class="subtle">Drop one player into another's room. The <b>joiner</b> is moved into the <b>target</b>'s current instance (the target must be in a room).</p>
<div class="fj-row">
<label>Person joining <input id="fj-joiner" type="text" inputmode="numeric" placeholder="joiner player id" /></label>
<i class="fa-solid fa-arrow-right fj-arrow"></i>
<label>Person being joined <input id="fj-target" type="text" inputmode="numeric" placeholder="target player id" /></label>
<button class="btn btn-info" onclick="forceJoin()"><i class="fa-solid fa-people-arrows"></i> Force Join</button>
<span id="fj-status" class="subtle"></span>
</div>
</div>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Id</th>
<th scope="col">Profile Image</th>
<th scope="col">Username</th>
<th scope="col">Platform Id</th>
<th scope="col">Device Id</th>
<th scope="col">Level</th>
<th scope="col">Player Created At</th>
<th scope="col">Last Online</th>
<th scope="col">Room</th>
<th scope="col">Discord</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="player-list">
</tbody>
</table>
<a style="cursor: pointer;" onclick="setup_html_show(`CreatePlayer`);" class="btn btn-success">Create Player</a>
</div>
<div class="content" id="Server">
<h2>Server</h2>
<div class="table" style="margin-bottom:18px;">
<h3>Server control</h3>
<p style="color:var(--muted); margin:0 0 12px;">Restart the game server process. Players are briefly disconnected; it comes back in a few seconds.</p>
<button class="btn btn-danger" onclick="restartServer()"><i class="fa-solid fa-power-off"></i> Restart Server</button>
<span id="server-restart-status" style="margin-left:12px; color:var(--muted); font-weight:600;"></span>
</div>
<div class="table" style="margin-bottom:18px;" id="social-apps">
<h3>Social sign-ins (website Services tab)</h3>
<p style="color:var(--muted); margin:0 0 12px;">Each service needs its own developer app. Paste the app's client id and secret here - saved to <code>Data/social_apps.json</code> on the server, never shown again. Register this redirect URI on every app: <code id="social-redirect-example">https://gameauth.ddns.net/api/site/social/&lt;provider&gt;/callback</code>. Steam needs no app (optional Web API key for persona names).</p>
<div class="card_2">
<table class="table table-striped">
<thead><tr><th>Service</th><th>Status</th><th>Client id</th><th>Client secret</th><th>Redirect URI</th><th></th></tr></thead>
<tbody id="social-apps-rows"><tr><td colspan="6">Loading...</td></tr></tbody>
</table>
</div>
</div>
<div class="table">
<h3>Community Board</h3>
<div class="card_2">
<p id="communityboard_player">Loading...</p>
</div>
<div class="card_2">
<table class="table table-striped">
<thead>
<tr>
<th scope="col">RoomId</th>
<th scope="col">ImageName</th>
<th scope="col">RoomName</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="communityboard_rooms">
</tbody>
</table>
<p id="communityboard_rooms_name">Loading...</p>
</div>
<div class="card_2">
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Id</th>
<th scope="col">ImageName</th>
<th scope="col">ImageUrl</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="communityboard_images">
</tbody>
</table>
<p id="communityboard_images_name">Loading...</p>
</div>
<div class="card_2">
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Id</th>
<th scope="col">Title</th>
<th scope="col">BlobName</th>
<th scope="col">Description</th>
<th scope="col">ThumbnailBlobName</th>
<th scope="col">SourceUrl</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="communityboard_videos">
</tbody>
</table>
<p id="communityboard_videos_name">Loading...</p>
</div>
<p id="communityboard_Message">Welcome to Campus</p>
</div>
</div>
<div class="content" id="Coach">
<h2>Coach</h2>
<input type="text" id="announcementText" placeholder="Enter announcement" style="display: block; width: 100%; padding: 10px; font-size: 16px; border: 1px solid #ccc;
border-radius: 5px; margin-bottom: 10px;" />
<input type="text" id="announcementid" placeholder="Player Id (optional)" style="display: block; width: 100%; padding: 10px; font-size: 16px; border: 1px solid #ccc;
border-radius: 5px; margin-bottom: 10px;" />
<button id="sendAnnoucement" style="display: block; background: var(--card-hover-bg); color: white; padding: 10px 15px; font-size: 16px; border: none;
cursor: pointer; border-radius: 5px; margin-bottom: 20px;" onclick="sendannoucement();">
Send Announcement
</button>
</div>
<div class="content" id="CreatePlayer">
<h2>Create Player</h2>
<input type="text" id="platformid" placeholder="Enter Platform ID" style="display: block; width: 100%; padding: 10px; font-size: 16px; border: 1px solid #ccc;
border-radius: 5px; margin-bottom: 10px;" />
<input type="text" id="discordid" placeholder="Enter Discord ID" style="display: block; width: 100%; padding: 10px; font-size: 16px; border: 1px solid #ccc;
border-radius: 5px; margin-bottom: 10px;" />
<button id="createplayer" style="display: block; background: var(--card-hover-bg); color: white; padding: 10px 15px; font-size: 16px; border: none;
cursor: pointer; border-radius: 5px; margin-bottom: 20px;" onclick="create_player();">
Create Player
</button>
</div>
<div class="content" id="EditPlayer">
<h2>Edit Player</h2>
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Player</h4>
<input type="text" id="editPlayerId" placeholder="Player Id" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<button style="display:block; background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:10px;" onclick="editLoad();">Load Player</button>
<div id="editSummary" style="margin-top:10px; line-height:1.7;"></div>
</div>
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Profile</h4>
<p style="opacity:.75; margin-top:0;">Leave a field blank to leave it unchanged.</p>
<div style="display:flex; gap:18px; align-items:flex-start; flex-wrap:wrap;">
<div style="text-align:center;">
<img id="editProfilePreview" src="/img/DefaultPFP.png" alt="profile picture"
style="width:120px; height:120px; border-radius:10px; object-fit:cover; background:rgba(0,0,0,.2);">
<div style="opacity:.7; font-size:13px; margin-top:6px;">preview</div>
</div>
<div style="flex:1; min-width:260px;">
<label style="display:block; margin-bottom:4px; opacity:.85;">@ Name (username)</label>
<input type="text" id="editusername" placeholder="e.g. Kyle" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<label style="display:block; margin-bottom:4px; opacity:.85;">Display Name (max 15)</label>
<input type="text" id="editdisplayname" placeholder="e.g. Kyle" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<label style="display:block; margin-bottom:4px; opacity:.85;">Profile picture (image name)</label>
<input type="text" id="editprofileimage" placeholder="e.g. DefaultPFP.png" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;"
oninput="editPreview();" />
<label style="display:block; margin-bottom:4px; opacity:.85;">Banner (image name)</label>
<input type="text" id="editbannerimage" placeholder="e.g. mybanner.png" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<label style="display:block; margin-bottom:4px; opacity:.85;">Steam IDs (SteamID64 &mdash; comma separated, several allowed)</label>
<textarea id="editsteamid" rows="2" placeholder="76561198000000001, 76561198000000002 &mdash; 0 to unlink all" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:4px; box-sizing:border-box; font-family:inherit; resize:vertical;"></textarea>
<p style="opacity:.65; font-size:13px; margin:0 0 10px;">
One account can hold several Steam IDs, and the same Steam ID can be put on
several accounts. Sharing is allowed &mdash; you'll just be told who else has it.
</p>
<label style="display:block; margin-bottom:4px; opacity:.85;">Discord ID (user id)</label>
<input type="text" id="editdiscordid" placeholder="e.g. 253148021237678080 &mdash; 0 to unlink" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<label style="display:block; margin-bottom:4px; opacity:.85;">Name lock</label>
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:10px;">
<span id="editNameLockState" style="opacity:.8;">&mdash;</span>
<button id="editNameLockBtn" type="button" style="background:var(--card-hover-bg); color:white; padding:8px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;" onclick="toggle_name_lock();">Lock names</button>
</div>
<p style="opacity:.65; font-size:13px; margin:0 0 10px;">
Locked players cannot change their @ name or display name in-game, on the
website or during onboarding. Edits made here still apply, so set the name you
want first, then lock it. Takes effect immediately.
</p>
</div>
</div>
<button id="save" style="display:block; background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:10px;" onclick="edit_player();">Save Changes</button>
<p style="opacity:.7; margin:0; font-size:14px;">
Image names refer to files the server can serve from <code>Data/Images</code>
(or an already-uploaded image). The preview shows what the client will fetch.
</p>
</div>
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Reset Password</h4>
<p style="opacity:.75; margin-top:0;">Sets this player's login password immediately. They are not notified &mdash; tell them the new password yourself.</p>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:6px;">
<input type="text" id="editnewpassword" placeholder="New password (4&ndash;64 characters)" autocomplete="off"
style="flex:1; min-width:220px; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; box-sizing:border-box;" />
<button type="button" onclick="genPassword();"
style="background:rgba(255,255,255,.12); color:inherit; padding:10px 14px; font-size:15px; border:1px solid #ccc; cursor:pointer; border-radius:5px;">Generate</button>
</div>
<button id="resetPwBtn" onclick="reset_password();"
style="display:block; background:#c0392b; color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:4px;">Reset Password</button>
<p style="opacity:.7; margin:0; font-size:14px;">Load a player above first. The password is sent over the connection and stored only as a hash &mdash; it is never logged in plain text.</p>
</div>
</div>
<div class="content" id="Events">
<h2>Player Events</h2>
<p style="opacity:.75; margin-top:0;">
Events players have scheduled. Event names and descriptions are now run through the
same word filter as room names, but anything created before that can be removed here.
</p>
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Room blobs</h4>
<p style="opacity:.7; margin-top:0; font-size:14px;">
Every <code>.room</code> file on the server. An <b>orphan</b> is a real saved build that
no room points at &mdash; attach it to a room and that room stops loading blank.
Getting it wrong costs nothing: attach a different blob and try again.
</p>
<button onclick="blobsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:12px;">Load blobs</button>
<div id="blobsSummary" style="opacity:.8; margin-bottom:10px;"></div>
<table class="table table-striped" style="width:100%;">
<thead><tr><th>Blob</th><th>Size</th><th>Used by</th><th>Attach to room id</th></tr></thead>
<tbody id="blobRows"><tr><td colspan="4" style="opacity:.7;">Press Load blobs.</td></tr></tbody>
</table>
</div>
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Import rooms</h4>
<p style="opacity:.7; margin-top:0; font-size:14px;">
Put a room JSON file in <code>Data/Imports</code> on the server, then enter its
filename here. See <code>Data/Imports/README.md</code> for the required shape.
Blobs are a separate step &mdash; the result lists any <code>.room</code> files still needed.
</p>
<input type="text" id="importFile" placeholder="rooms.json" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<button onclick="importRooms();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Import</button>
<pre id="importResult" style="white-space:pre-wrap; margin:12px 0 0; opacity:.85; font-size:13px;"></pre>
</div>
<button onclick="eventsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:14px;">Refresh</button>
<div style="background:var(--table-bg); border-radius:10px; padding:18px;">
<table class="table table-striped" style="width:100%;">
<thead>
<tr>
<th>Id</th><th>Name</th><th>Creator</th><th>Room</th><th>Starts</th><th>Going</th><th></th>
</tr>
</thead>
<tbody id="eventsRows">
<tr><td colspan="7" style="opacity:.7;">Press Refresh to load.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="content" id="Photos">
<h2>Photos</h2>
<p style="opacity:.75; margin-top:0;">Every photo taken in the game, newest first, attributed to the player who took it.</p>
<button onclick="photosLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:14px;">Refresh</button>
<div id="photosSummary" style="opacity:.8; margin-bottom:12px;"></div>
<div id="photoGrid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:16px;"></div>
</div>
<div class="content" id="Comments">
<h2>Room Comments</h2>
<p style="opacity:.75; margin-top:0;">Notes players pin in a room's scene, newest first, attributed to the author.</p>
<button onclick="commentsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:14px;">Refresh</button>
<div id="commentsSummary" style="opacity:.8; margin-bottom:12px;"></div>
<div id="commentsList" style="display:flex; flex-direction:column; gap:10px;"></div>
</div>
<div class="content" id="Reports">
<h2>Player Reports</h2>
<p style="opacity:.75; margin-top:0;">Reports raised from the in-game report menu and the website's Report buttons, newest first. Acting on a player also closes their other open reports, so a pile-on does not leave duplicates of what you just handled.</p>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px;">
<button onclick="reportsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Refresh</button>
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="reportsShowAll" onchange="reportsLoad();" /> Include resolved</label>
<input type="text" id="reportsPlayerFilter" placeholder="Filter by reported player id" style="padding:9px; font-size:15px; border:1px solid #ccc; border-radius:5px; min-width:230px;" onkeydown="if(event.key==='Enter')reportsLoad();" />
</div>
<div id="reportsSummary" style="opacity:.8; margin-bottom:12px;"></div>
<div id="reportsList" style="display:flex; flex-direction:column; gap:10px;"></div>
<h2 style="margin-top:30px;">Bug Reports</h2>
<p style="opacity:.75; margin-top:0;">What players send from the watch's <b>Report a bug</b>. Every field the client posted is kept (expand <i>raw</i>); attached screenshots / logs are linked.</p>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px;">
<button onclick="bugsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Refresh</button>
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="bugsShowAll" onchange="bugsLoad();" /> Include resolved</label>
</div>
<div id="bugsSummary" style="opacity:.8; margin-bottom:12px;"></div>
<div id="bugsList" style="display:flex; flex-direction:column; gap:10px;"></div>
</div>
<div class="content" id="Staff">
<h2>Staff &amp; Moderators</h2>
<p style="opacity:.75; margin-top:0;">Who holds which flag. <b>Moderator</b> is the vmod role the client checks for the badge and moderation powers; <b>Developer</b> also grants access to this admin panel, so hand it out carefully. Changes take effect when that player next signs in &mdash; roles are baked into their token when it is issued.</p>
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
<label style="display:block; margin-bottom:6px;">Player id</label>
<input type="text" id="staffPlayerId" placeholder="Player id" style="width:220px; padding:9px; font-size:15px; border:1px solid #ccc; border-radius:5px; box-sizing:border-box;" />
<div style="margin-top:12px; display:flex; gap:16px; align-items:center; flex-wrap:wrap;">
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="staffModerator" /> Moderator (vmod)</label>
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="staffScreenshare" /> Screenshare</label>
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="staffSupport" /> Support</label>
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="staffDeveloper" /> Developer <span style="opacity:.6; font-size:12px;">(admin panel access)</span></label>
</div>
<div style="margin-top:14px; display:flex; gap:10px;">
<button onclick="staffApply();" style="background:var(--card-hover-bg); color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Apply roles</button>
<button onclick="staffLoadOne();" style="background:#555; color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Load current</button>
</div>
</div>
<button onclick="staffLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:14px;">Refresh list</button>
<div id="staffSummary" style="opacity:.8; margin-bottom:12px;"></div>
<div id="staffList" style="display:flex; flex-direction:column; gap:8px;"></div>
</div>
<div class="content" id="Featured">
<h2>Featured</h2>
<p style="opacity:.75; margin-top:0;">What the game and the website show as <b>featured</b>: the rooms (Play tab "Featured This Week", the banner, the community board's featured room) and the players (the community board's <b>Featured Creator</b> tile and the Play tab's Featured Creator). Changes are live on the next fetch - no restart.</p>
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Featured Rooms</h4>
<p style="opacity:.75; margin-top:0;">The rooms shown as <b>Featured</b> in-game and on the website home page. Enter room IDs in the order you want them shown (comma, space or newline separated). Copy IDs from the table below.</p>
<div id="featuredCurrent" style="margin-bottom:10px; line-height:1.7;"></div>
<textarea id="featuredIds" rows="3" placeholder="7243703403195289000, 12, 46 &mdash; leave empty to feature nothing"
style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box; font-family:inherit; resize:vertical;"></textarea>
<button onclick="saveFeaturedRooms();"
style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Save Featured Rooms</button>
<button type="button" onclick="loadFeaturedRooms();"
style="background:rgba(255,255,255,.12); color:inherit; padding:10px 14px; font-size:15px; border:1px solid #ccc; cursor:pointer; border-radius:5px; margin-left:6px;">Reload</button>
</div>
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Featured Players</h4>
<p style="opacity:.75; margin-top:0;">Player ids or @usernames, in order - the <b>first</b> one is the Featured Creator. Leave empty to fall back to the player with the most public rooms.</p>
<div id="featuredPlayersCurrent" style="margin-bottom:10px; line-height:1.7;"></div>
<textarea id="featuredPlayerIds" rows="2" placeholder="6, @Kyle, 277"
style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box; font-family:inherit; resize:vertical;"></textarea>
<label style="display:block; margin-bottom:6px;">Community board announcement</label>
<input type="text" id="featuredAnnouncement" placeholder="Shown on the community board (leave empty to keep the file's text)"
style="display:block; width:100%; padding:10px; font-size:15px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<input type="text" id="featuredAnnouncementUrl" placeholder="More-info link (optional)"
style="display:block; width:100%; padding:10px; font-size:15px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<button onclick="saveFeaturedPlayers();"
style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Save Featured Players</button>
<button type="button" onclick="loadFeaturedPlayers();"
style="background:rgba(255,255,255,.12); color:inherit; padding:10px 14px; font-size:15px; border:1px solid #ccc; cursor:pointer; border-radius:5px; margin-left:6px;">Reload</button>
</div>
</div>
<div class="content" id="Gifts">
<h2>Gifts</h2>
<p style="opacity:.75; margin-top:0;">Search <b>every storefront and the whole avatar catalog</b> (<code>avatar_items.json</code>) as you type, and give an item to a player as an <b>unopened gift box</b> they open in-game, or drop it <b>straight into their wardrobe</b>. Use player id <code>-1</code> for <b>all players</b> (owners are skipped). Items tagged <b>catalog</b> are not sold anywhere, so they have no gift-drop art &mdash; prefer <b>straight into their wardrobe</b> for those.</p>
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
<label style="display:block; margin-bottom:6px;">Player id <span style="opacity:.6;">(or -1 for everyone)</span></label>
<input type="text" id="giftPlayerId" placeholder="Player id" style="width:220px; padding:9px; font-size:15px; border:1px solid #ccc; border-radius:5px; box-sizing:border-box;" />
<div style="margin-top:12px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<input type="text" id="giftSearchBox" placeholder="Search items (e.g. helmet, coat)..." style="flex:1; min-width:240px; padding:9px; font-size:15px; border:1px solid #ccc; border-radius:5px; box-sizing:border-box;" oninput="giftSearchLive();" onkeydown="if(event.key==='Enter'){event.preventDefault();giftSearch();}" />
<button onclick="giftSearch();" style="background:var(--card-hover-bg); color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Search</button>
</div>
<div style="margin-top:12px; display:flex; gap:14px; align-items:center; flex-wrap:wrap;">
<span>Deliver as
<select id="giftMode" style="padding:7px; border-radius:5px;">
<option value="box">Gift box (opens in-game)</option>
<option value="wardrobe">Straight to wardrobe</option>
</select>
</span>
<span>Bonus XP <input type="number" id="giftXp" value="0" min="0" style="width:80px; padding:7px; border:1px solid #ccc; border-radius:5px;" /></span>
<span style="flex:1; min-width:200px;">Message <input type="text" id="giftMessage" placeholder="A gift for you <3" style="width:100%; padding:7px; border:1px solid #ccc; border-radius:5px; box-sizing:border-box;" /></span>
</div>
</div>
<div id="giftSummary" style="opacity:.8; margin-bottom:12px;"></div>
<div id="giftResults" style="display:flex; flex-direction:column; gap:8px;"></div>
</div>
<div class="content" id="Economy">
<h2>Economy</h2>
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin:14px 0 22px;">
<h4 class="section-title">Creator Earnings</h4>
<p style="opacity:.75; margin-top:0;">A maker gets <b>30%</b> of what a buyer spends on their invention / custom clothing / room key / room currency / room consumable. Each sale pends <b>14 days</b> and is paid on the first <b>Sunday</b> after that (00:00 UTC). Coach and the Importer never earn.</p>
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:10px;">
<button onclick="earningsLoad();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Refresh</button>
<button onclick="earningsPayNow();" style="background:#555; color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Pay everything due now</button>
<span id="earningsSummary" style="opacity:.8;"></span>
</div>
<div id="earningsList" style="max-height:420px; overflow:auto; font-size:14px;"></div>
</div>
<!-- In-game account creation test switch -->
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Test in-game account creation</h4>
<p style="opacity:.75; margin-top:0;">While ON, launching the game from an admin-allowlisted IP shows an empty account picker, so the client creates a brand-new account in-game (Orientation: choose name, password). Turn it OFF to log into your own account again. Allowlisted IPs also skip the signup / login / device limits.</p>
<span id="newAcctState" style="display:inline-block; margin-bottom:10px;">state: &hellip;</span><br>
<button style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-right:8px;" onclick="setNewAcctTest(1);">Turn ON</button>
<button style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;" onclick="setNewAcctTest(0);">Turn OFF</button>
</div>
<!-- Look up a player once; every section below acts on this id. -->
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Player</h4>
<input type="text" id="econPlayerId" placeholder="Player Id" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<button style="display:block; background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:10px;" onclick="econLoad();">Load Player</button>
<div id="econSummary" style="margin-top:10px; line-height:1.7;"></div>
</div>
<!-- Currency -->
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Give Balance</h4>
<p style="opacity:.75; margin-top:0;">A positive amount of currency is delivered as a gift box and credited when the player opens it. XP is added directly (a level crossed sends its level-up box). Negative takes currency away immediately. <b>Level</b>: the amount is the level to set the player to.</p>
<select id="econCurrency" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;">
<option value="1">Laser Tag Tickets</option>
<option value="2" selected>Rec Center Tokens</option>
<option value="xp">XP (added straight to their bar)</option>
<option value="level">Level (sets the player to that level)</option>
<option value="100">Lost Skulls Gold (Isle)</option>
<option value="101">Dracula Silver (Crescendo)</option>
<option value="200">Rec Royale Season 1</option>
<option value="300">Room Currency</option>
<option value="301">Room Inventory Item</option>
<option value="400">Progression Event</option>
<option value="500">Roomie Credits</option>
<option value="600">Paintball Paint</option>
</select>
<input type="number" id="econAmount" placeholder="Amount (e.g. 1000)" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<select id="econBoxType" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;">
<option value="51" selected>Box look: Tokens box</option>
<option value="50">Box look: Game reward (XP) box</option>
<option value="4003">Box look: Golden Trophy</option>
<option value="4503">Box look: Jumbotron</option>
<option value="4013">Box look: Crimson Cauldron</option>
<option value="4103">Box look: Isle of Lost Skulls</option>
<option value="4206">Box look: Crescendo</option>
<option value="0">Box look: Plain gift</option>
</select>
<input type="text" id="econBoxMessage" maxlength="120" placeholder="Box message (e.g. Thanks for playing!) - shown on the gift box" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:10px;" onclick="econGrantBalance();">Give Balance</button>
<button style="background:#8e44ad; color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:10px;" onclick="econGrantBalanceAll();" title="Apply this to every account on the server">Send to all</button>
</div>
</div>
<!-- Gift boxes -->
<div style="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Send Game Reward</h4>
<p style="opacity:.75; margin-top:0;">Sends the player the end-of-game reward picker (3 token cards, 10&ndash;1000) in the chosen box, exactly as finishing that activity would.</p>
<select id="econGiftBox" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;">
<option value="4003">Golden Gift Box</option>
<option value="4503">Jumbotron Gift Box</option>
<option value="4013">Crimson Gift Box</option>
<option value="4103">Isle Gift Box</option>
<option value="4206">Crescendo Gift Box</option>
<option value="5001">Charades Gift Box</option>
<option value="10001">Bowling Gift Box</option>
<option value="1200">Rec Royale Gift Box</option>
<option value="12000">Rec Rally Gift Box</option>
<option value="12100">Showdown Gift Box</option>
<option value="12200">My Little Monster Gift Box</option>
<option value="12300">Run The Block Gift Box</option>
<option value="11007">Stunt Runner Gift Box</option>
<option value="3010">Disc Golf Propulsion Gift Box</option>
<option value="3011">Disc Golf Lake Gift Box</option>
<option value="2010">Paintball Clearcut Gift Box</option>
<option value="2011">Paintball Homestead Gift Box</option>
<option value="2012">Paintball Quarry Gift Box</option>
<option value="2013">Paintball River Gift Box</option>
<option value="2014">Paintball Dam Gift Box</option>
<option value="2015">Paintball Drive In Gift Box</option>
<option value="2016">Paintball Merry Midway Gift Box</option>
</select>
<input type="text" id="econGiftMessage" placeholder="Picker title (default: Game Complete)" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:10px;" onclick="econGiftBox();">Send Game Reward</button>
<button style="background:#8e44ad; color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:10px;" onclick="econGiftBoxAll();" title="Send this reward picker to every account on the server">Send to all</button>
</div>
</div>
</div>
<div class="content" id="Database">
<h2>Database</h2>
<p>Automatic snapshots of every database run 3&times; a day and are kept for 3 days. Restore a snapshot if something goes wrong.</p>
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin:16px 0 22px;">
<button class="btn btn-info" onclick="backupNow()"><i class="fa-solid fa-floppy-disk"></i> Back up now</button>
<button class="btn btn-secondary" onclick="loadBackups()"><i class="fa-solid fa-rotate"></i> Refresh</button>
<span id="db-status" style="color:var(--muted); font-weight:600;"></span>
</div>
<table class="table" style="width:100%;">
<thead><tr><th>Backup</th><th>Created (UTC)</th><th>Files</th><th>Size</th><th>Actions</th></tr></thead>
<tbody id="backup-list">
<tr><td colspan="5" style="color:var(--muted);">Loading…</td></tr>
</tbody>
</table>
</div>
<div class="content" id="Settings">
<h2>Settings</h2>
<!-- ================= STEAM ALLOWLIST ================= -->
<!-- Gates STEAM logins only. Quest/Meta and every other platform ignore it. -->
<div class="card" style="margin-bottom:18px;">
<h3><i class="fa-brands fa-steam"></i> Steam Allowlist</h3>
<p id="steamAllowState" style="opacity:.85; margin:6px 0 12px;">Loading&hellip;</p>
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:12px;">
<input id="steamAllowInput" type="text" inputmode="numeric" placeholder="SteamID64 (17 digits)"
style="flex:1 1 260px; min-width:200px; padding:9px 12px; border-radius:8px;
border:1px solid rgba(255,255,255,.18); background:rgba(0,0,0,.25);
color:#eee; font-family:inherit;">
<button class="btn btn-info" onclick="steamAllowAdd()">Add</button>
<button class="btn" onclick="steamAllowLoad()">Refresh</button>
</div>
<div id="steamAllowList" style="max-height:320px; overflow:auto;"></div>
<p style="opacity:.7; font-size:13px; margin-top:12px;">
An <strong>empty</strong> list means the check is <strong>off</strong> and every Steam
user can connect. Quest / Meta players are never affected. Stored in
<code>Data/steam_allowlist.txt</code>.
</p>
</div>
<!-- ================= DANGER ZONE ================= -->
<!-- Kill-switch for in-game bearer tokens. Website/admin sessions are NOT touched. -->
<div class="card" style="margin-bottom:18px; border:1px solid rgba(255,80,80,.40);">
<h3><i class="fa-solid fa-triangle-exclamation"></i> Danger Zone</h3>
<p style="opacity:.85; margin:6px 0 12px;">
Expire <strong>every in-game sign-in</strong> at once. Use this if a token or password
leaked: all players are signed out of the <strong>game</strong> and must log in again.
Website and admin logins are <strong>not</strong> affected.
</p>
<button class="btn" style="background:#c0392b; color:#fff;" onclick="revokeGameTokens()">
<i class="fa-solid fa-key"></i> Expire all in-game tokens
</button>
<p id="revokeState" style="opacity:.7; font-size:13px; margin-top:10px;"></p>
</div>
<div class="dropdown-container" style="margin-bottom: 10px;">
<label for="background-selector" style="font-size: 16px; color: white;">Select Background Image</label>
<select id="background-selector" style="margin-left: 1.5px; padding: 5px;">
<option value="Stary_background">Stary Background (OG)</option>
<option value="dorm_room.jpg">Dorm Room</option>
<option value="rec_center.png">Rec Center</option>
<option value="FriendFest2.png">Friend Fest</option>
<option value="Orientation.png">Orientation</option>
<option value="Lounge">Lounge</option>
<option value="3dcharades.png">3D Charades</option>
<option value="wwww.png">Cool thing idk what is it</option>
</select>
</div>
<div class="dropdown-container" style="margin-bottom: 10px;">
<label for="Theme" style="font-size: 16px; color: white;">Select Theme</label>
<select id="theme-selector" style="margin-left: 10px; padding: 5px;">
<option value="campus_theme" selected>Campus (default)</option>
<option value="red_theme">Red Theme</option>
<option value="green_theme">Green Theme</option>
<option value="blue_theme">Blue Theme</option>
<option value="purple_theme">Purple Theme (OG)</option>
<option value="pink_theme">Pink Theme</option>
<option value="orange_theme">Orange Theme</option>
<option value="yellow_theme">Yellow Theme</option>
<option value="dark_theme">Dark Theme</option>
</select>
</div>
<div class="MuiFormGroup-root css-1mgm2yh-control">
<label class="MuiFormLabel-root MuiFormLabel-colorPrimary css-bngtud-label">
Enable Mobile Support
(beta)
</label>
<label class="switch">
<input type="checkbox" id="mobile-support">
<span class="slider round"></span>
</label>
</span>
</div>
<div class="MuiFormGroup-root css-1mgm2yh-control">
<label class="MuiFormLabel-root MuiFormLabel-colorPrimary css-bngtud-label">
Enable Better Popups
(beta)
</label>
<label class="switch">
<input type="checkbox" id="better-popup">
<span class="slider round"></span>
</label>
</span>
</div>
<div class="MuiFormGroup-root css-1mgm2yh-control">
<label class="MuiFormLabel-root MuiFormLabel-colorPrimary css-bngtud-label">allow notifiyed</label>
<label class="switch">
<input type="checkbox" id="allow_notifiyed">
<span class="slider round"></span>
</label>
</span>
</div>
</div>
<div class="content" id="Logs">
<h2>Logs</h2>
<div id="logs-content"></div>
</div>
<div class="content" id="Inventions">
<h2>Inventions</h2>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Invention Id</th>
<th scope="col">Image</th>
<th scope="col">Name</th>
<th scope="col">Created At</th>
<th scope="col">Modified At</th>
<th scope="col">Permissions</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="inventions_list">
</tbody>
</table>
</div>
<div class="content" id="Clubs">
<h2>Clubs</h2>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Club Id</th>
<th scope="col">Image</th>
<th scope="col">Name</th>
<th scope="col">Created At</th>
<th scope="col">Members</th>
<th scope="col">None</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="clubs_list">
</tbody>
</table>
</div>
<div class="content" id="Rooms">
<h2>Rooms</h2>
<div style="background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px; margin-bottom:22px;">
<h4 class="section-title">Import rooms (into @Importer)</h4>
<p style="color:var(--muted); margin-top:0;">Drop a room-export <code>.zip</code> (RoomDetails.json + subrooms + blobs + image) and it's unpacked, imported as a room owned by <b>@Importer</b> (ids reassigned, kept published), with its blobs placed on the CDN so the build loads. You can also drop loose <code>.room</code> blobs &mdash; each is hosted and wrapped in a new joinable <b>@Importer</b> room named after the file (or a <b>zip full of <code>.room</code> files</b> to do a whole batch at once); saves too new for this client are hosted but skipped so they don't freeze the game. Loose <code>.json</code> exports work too. Multiple at once is fine.</p>
<div id="importDrop" tabindex="0"
style="border:2px dashed var(--line); border-radius:14px; padding:34px 18px; text-align:center; cursor:pointer; transition:.15s; color:var(--muted);">
<div style="font-size:2rem; line-height:1; margin-bottom:8px;"><i class="fa-solid fa-cloud-arrow-up"></i></div>
<div style="font-weight:700; color:var(--text);">Drag &amp; drop a room .zip here</div>
<div style="font-size:.85rem; margin-top:4px;">or click to choose files (.zip, .json, .room, .META)</div>
</div>
<input type="file" id="importFileInput" multiple accept=".zip,.json,.room,.META,application/zip,application/json" style="display:none;">
<div id="importStatus" style="margin-top:12px; font-weight:600;"></div>
<div id="importResult" style="margin-top:8px; color:var(--muted); font-size:.9rem; white-space:pre-wrap;"></div>
</div>
<div style="display:flex; gap:10px; align-items:center; margin:10px 0; flex-wrap:wrap;">
<input id="roomsSearch" type="text" placeholder="Search rooms by name, description, id, or creator id…"
style="flex:1; min-width:240px; padding:8px 12px; border:1px solid var(--border); border-radius:6px; background:var(--card); color:var(--text);"
onkeydown="if(event.key==='Enter'){loadRooms(this.value);}">
<button class="btn btn-info" onclick="loadRooms(document.getElementById('roomsSearch').value)">Search</button>
<button class="btn btn-info" onclick="loadRooms('')">Clear</button>
</div>
<div style="display:flex; gap:10px; align-items:center; margin:6px 0 12px; flex-wrap:wrap;">
<button id="rooms_prev" class="btn btn-info" onclick="roomsPage(-1)">‹ Prev</button>
<button id="rooms_next" class="btn btn-info" onclick="roomsPage(1)">Next ›</button>
<span id="rooms_count" style="color:var(--muted); font-size:.9rem;"></span>
</div>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Room Id</th>
<th scope="col">Image</th>
<th scope="col">Room Name</th>
<th scope="col">Description</th>
<th scope="col">Creator Player Id</th>
<th scope="col">Max Players</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="rooms_list">
</tbody>
</table>
</div>
<script src="/admin/home_page.js"></script>
<script>
function splitTest() {
}
document.getElementById('mobile-support').addEventListener('change', function () {
localStorage.setItem('mobileSupport', this.checked);
toggleMobileSupport();
});
document.getElementById('better-popup').addEventListener('change', function () {
localStorage.setItem('betterPopup', this.checked);
toggleBetterPopups();
});
document.getElementById('background-selector').addEventListener('change', function () {
const selectedImage = this.value;
if (selectedImage) {
localStorage.setItem('backgroundImage', selectedImage);
setBackgroundImage(selectedImage);
}
});
document.addEventListener('DOMContentLoaded', function () {
const savedBackground = localStorage.getItem('backgroundImage');
if (savedBackground) {
document.getElementById('background-selector').value = savedBackground;
setBackgroundImage(savedBackground);
}
});
function setBackgroundImage(imageName) {
const backgroundLayer = document.getElementById('background-blur-layer');
if (imageName === "Stary_background") {
backgroundLayer.style.backgroundImage = ``;
document.getElementById('space-background').style.visibility = "visible";
return;
}
const imageUrl = `/img/${imageName}`;
backgroundLayer.style.backgroundImage = `url(${imageUrl})`;
document.getElementById('space-background').style.visibility = "hidden";
}
function toggleMobileSupport() {
const isChecked = document.getElementById('mobile-support').checked;
if (isChecked) {
document.body.classList.add('mobile-support');
} else {
document.body.classList.remove('mobile-support');
}
}
function toggleBetterPopups() {
const isChecked = document.getElementById('better-popup').checked;
if (isChecked) {
document.body.classList.add('better-popup');
} else {
document.body.classList.remove('better-popup');
}
}
function closeBanModal() {
document.getElementById("ban-modal").classList.remove("active");
}
function create_player() {
var platformId = document.getElementById("platformid").value;
var discordId = document.getElementById("discordid").value;
if (!platformId) {
showPopup("Warning!", "Please fill in both Platform ID");
return;
}
var formData = new FormData();
formData.append('platform_id', platformId);
formData.append('discord_id', discordId);
formData.append('device_id', "1");
fetch('/admin/api/create_player', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
showPopup('Success!', 'Player created successfully!');
viewPlayer(data, false)
})
.catch(error => {
showPopup('Error!', error.message || error);
});
}
function ban() {
var playerId = document.getElementById("playerid").value;
var message = document.getElementById("banmsg").value;
var category = document.getElementById("banCategory").value;
const includeAppeal = document.getElementById("includeAppeal")?.checked === true;
const amountInput = document.getElementById("banAmount");
const unit = document.getElementById("banUnit").value;
let totalSeconds = 0;
if (unit === "permanent") {
totalSeconds = INT32_MAX; // backend convention
} else {
const amount = parseInt(amountInput.value, 10);
if (isNaN(amount) || amount <= 0) {
alert("Please enter a valid ban duration.");
return;
}
const multiplier = unitMultipliers[unit];
totalSeconds = amount * multiplier;
// INT32 clamp (absolute safety)
if (totalSeconds > INT32_MAX) {
totalSeconds = INT32_MAX;
}
}
// 🔒 Final payload
if (!message) {
showPopup('Error!', 'Reason is required.');
return;
}
if (totalSeconds <= 0) {
showPopup('Error!', 'Duration must be more than 0.');
return;
}
var formData = new FormData();
formData.append('playerId', playerId);
formData.append('message', message);
formData.append('duration', totalSeconds);
formData.append('category', category);
formData.append("includeAppeal", includeAppeal.toString());
// Optional device / IP bans. An account ban alone is trivially defeated by
// making a new account; these are enforced at login, before the account is
// even looked up.
formData.append("banDevice", (document.getElementById("banDevice")?.checked === true) ? "1" : "0");
formData.append("banIp", (document.getElementById("banIp")?.checked === true) ? "1" : "0");
fetch('/admin/api/ban_player', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success === false) {
showPopup('Error!', data.error || 'Unknown error');
return;
}
setup_html_show('players');
showPopup('Success!', 'Player banned successfully!');
})
.catch(error => {
showPopup('Error!', error.error || error);
});
}
// ---- Edit Player ---------------------------------------------------
// Reads/writes another player's @name, display name, profile picture and banner.
// Every field is optional: blank means "leave it alone", so one Save can change
// any combination without wiping the rest.
function editId() {
const raw = (document.getElementById('editPlayerId').value || '').trim();
if (!raw) {
showPopup('Error!', 'Enter a player id first.');
return null;
}
return raw;
}
function editPreview() {
const name = (document.getElementById('editprofileimage').value || '').trim();
const img = document.getElementById('editProfilePreview');
img.src = name ? ('/img/' + encodeURIComponent(name) + '?width=256&cropSquare=1&sig=p1')
: '/img/DefaultPFP.png';
}
function editLoad() {
const id = editId();
if (!id) return;
fetch('/admin/api/get_profile?playerId=' + encodeURIComponent(id), { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const box = document.getElementById('editSummary');
if (!data.success) {
box.innerHTML = '<span style="opacity:.8;">' + (data.message || 'Not found.') + '</span>';
return;
}
document.getElementById('editusername').value = data.username || '';
document.getElementById('editdisplayname').value = data.displayName || '';
document.getElementById('editprofileimage').value = data.profileImage || '';
document.getElementById('editbannerimage').value = data.bannerImage || '';
const steamIds = data.steamIds || [];
document.getElementById('editsteamid').value = steamIds.join(', ');
// '0' means not linked - show it blank rather than a literal zero.
document.getElementById('editdiscordid').value = (data.discordId && data.discordId !== '0') ? data.discordId : '';
editPreview();
editNameLockShow(!!data.nameLocked);
const hasDiscord = data.discordId && data.discordId !== '0';
const discordLabel = hasDiscord
? ((data.discordUsername ? (data.discordUsername + ' ') : '') + '(' + data.discordId + ')')
: 'not linked';
box.innerHTML = '<b>@' + (data.username || '?') + '</b> (id ' + data.playerId + ')<br>'
+ 'Display name: <b>' + (data.displayName || '-') + '</b><br>'
+ 'Steam: <b>' + (steamIds.length ? steamIds.join(', ') : 'not linked') + '</b>'
+ ' &nbsp;|&nbsp; Discord: <b>' + discordLabel + '</b>'
+ (data.nameLocked ? '<br><b style="color:#e67e22;">Name locked</b> &mdash; this player cannot rename themselves.' : '');
})
.catch(err => showPopup('Error!', 'Lookup failed: ' + err));
}
// ---- Name lock (Edit Player) ---------------------------------------
// The button toggles the stored flag; editLoad() re-reads it so the label always shows
// what the server holds rather than what was clicked.
let editNameLocked = false;
function editNameLockShow(locked) {
editNameLocked = !!locked;
document.getElementById('editNameLockState').innerHTML = editNameLocked
? '<b style="color:#e67e22;">LOCKED</b> &mdash; player cannot change their names'
: 'Unlocked &mdash; player may rename themselves';
const btn = document.getElementById('editNameLockBtn');
btn.textContent = editNameLocked ? 'Unlock names' : 'Lock names';
btn.style.background = editNameLocked ? '#c0392b' : 'var(--card-hover-bg)';
}
function toggle_name_lock() {
const id = editId();
if (!id) return;
const form = new URLSearchParams();
form.append('playerId', id);
form.append('locked', editNameLocked ? 'false' : 'true');
fetch('/admin/api/set_name_lock', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Saved' : 'Error!', data.message || (data.success ? 'Done.' : 'Failed.'));
if (data.success) editLoad();
})
.catch(err => showPopup('Error!', 'Name lock failed: ' + err));
}
// ---- Events tab -----------------------------------------------------
// Same idiom as the other admin actions: fetch with credentials, expect
// {success, ...}, report via showPopup.
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
{ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
function blobsLoad() {
fetch('/admin/api/room_blobs', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const body = document.getElementById('blobRows');
const sum = document.getElementById('blobsSummary');
if (!data.success) { body.innerHTML = '<tr><td colspan="4">' + esc(data.message) + '</td></tr>'; return; }
sum.textContent = data.total + ' files — ' + data.orphans + ' orphaned build(s), ' +
data.empty + ' empty (failed downloads, ignore).';
const rows = (data.blobs || []).filter(b => !b.empty);
body.innerHTML = rows.map(b =>
'<tr>' +
'<td style="font-family:monospace; font-size:12px;">' + esc(b.name) + '</td>' +
'<td>' + (b.size / 1024).toFixed(1) + ' KB</td>' +
'<td>' + (b.usedBy ? esc(b.usedBy) : '<b style="color:#e67e22;">orphan</b>') + '</td>' +
'<td><input id="atr_' + esc(b.name) + '" style="width:90px; padding:5px;" placeholder="room id"> ' +
'<button onclick="attachBlob(\'' + esc(b.name) + '\')" style="background:var(--card-hover-bg); color:white; border:none; padding:5px 10px; border-radius:5px; cursor:pointer;">Attach</button></td>' +
'</tr>').join('') || '<tr><td colspan="4" style="opacity:.7;">No usable blobs.</td></tr>';
})
.catch(err => showPopup('Error!', 'Load failed: ' + err));
}
function socialAppsLoad() {
const tb = document.getElementById('social-apps-rows');
if (!tb) return;
fetch('/admin/api/social_apps', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => {
if (!d.success) { tb.innerHTML = '<tr><td colspan="6">Could not load.</td></tr>'; return; }
tb.innerHTML = d.providers.map(p => {
const status = p.configured ? '<span style="color:#2ecc71;font-weight:600">Ready</span>' : '<span style="color:var(--muted)">Not set up</span>';
const idCell = p.usesApiKey
? '<span style="color:var(--muted)">n/a</span>'
: '<input class="form-control" style="min-width:220px" id="soc-id-' + p.key + '" value="' + (p.clientId || '').replace(/"/g, '&quot;') + '" placeholder="client id">';
const secretCell = p.usesApiKey
? '<input class="form-control" style="min-width:220px" id="soc-key-' + p.key + '" placeholder="' + (p.apiKeySet ? 'Web API key set - paste to replace' : 'Steam Web API key (optional)') + '">'
: '<input class="form-control" type="password" style="min-width:220px" id="soc-secret-' + p.key + '" placeholder="' + (p.secretSet ? 'secret set - paste to replace' : 'client secret') + '">';
return '<tr><td><b>' + p.label + '</b></td><td>' + status + '</td><td>' + idCell + '</td><td>' + secretCell + '</td>'
+ '<td><code style="font-size:.8rem">' + p.redirectUri + '</code></td>'
+ '<td><button class="btn btn-success" onclick="socialAppSave(\'' + p.key + '\', ' + (p.usesApiKey ? 'true' : 'false') + ')">Save</button></td></tr>';
}).join('');
})
.catch(err => { tb.innerHTML = '<tr><td colspan="6">Failed: ' + err + '</td></tr>'; });
}
function socialAppSave(key, usesApiKey) {
const form = new URLSearchParams();
form.append('provider', key);
if (usesApiKey) form.append('apiKey', (document.getElementById('soc-key-' + key) || {}).value || '');
else {
form.append('clientId', (document.getElementById('soc-id-' + key) || {}).value || '');
form.append('clientSecret', (document.getElementById('soc-secret-' + key) || {}).value || '');
}
fetch('/admin/api/social_apps', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString() })
.then(r => r.json())
.then(d => { showPopup(d.success ? 'Saved' : 'Error!', d.message || 'Failed.'); socialAppsLoad(); })
.catch(err => showPopup('Error!', 'Save failed: ' + err));
}
function attachBlob(name) {
const el = document.getElementById('atr_' + name);
const rid = (el && el.value || '').trim();
if (!rid) { showPopup('Error!', 'Enter a room id.'); return; }
const form = new URLSearchParams();
form.append('blob', name);
form.append('roomId', rid);
fetch('/admin/api/attach_blob', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Attached' : 'Error!', data.message || 'Failed.');
if (data.success) blobsLoad();
})
.catch(err => showPopup('Error!', 'Attach failed: ' + err));
}
function importRooms() {
const f = (document.getElementById('importFile').value || '').trim();
if (!f) { showPopup('Error!', 'Enter a filename from Data/Imports.'); return; }
const out = document.getElementById('importResult');
out.textContent = 'Importing ' + f + ' ...';
const form = new URLSearchParams();
form.append('file', f);
fetch('/admin/api/import_rooms', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
if (!data.success) { out.textContent = data.message || 'Import failed.'; return; }
let t = data.message + '\n\nrooms: ' + data.roomsBefore + ' -> ' + data.roomsAfter +
'\nblobs present: ' + data.blobsPresent;
const miss = data.blobsMissing || [];
if (miss.length) t += '\n\n.room files still needed in Data/CDN/RoomBlobs:\n ' + miss.join('\n ');
out.textContent = t;
showPopup('Imported', data.message);
})
.catch(err => { out.textContent = 'Import failed: ' + err; });
}
function photosLoad() {
fetch('/admin/api/photos', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const grid = document.getElementById('photoGrid');
const sum = document.getElementById('photosSummary');
if (!data.success) { grid.innerHTML=''; sum.textContent = data.message || 'Failed.'; return; }
sum.textContent = data.count + ' photo(s).';
const items = data.photos || [];
if (items.length === 0) { grid.innerHTML = '<span style="opacity:.7;">No photos.</span>'; return; }
grid.innerHTML = items.map(p => {
const img = p.imageUrl ? '<img src="'+esc(p.imageUrl)+'" style="width:100%; height:150px; object-fit:cover; border-radius:6px; background:rgba(0,0,0,.2);" loading="lazy" onerror="this.style.opacity=.3">' : '<div style="height:150px; border-radius:6px; background:rgba(0,0,0,.2);"></div>';
const when = (p.createdAt || '').replace('T',' ').slice(0,16);
const tags = (p.taggedPlayers && p.taggedPlayers.length) ? '<div style="opacity:.6; font-size:12px;">with '+esc(p.taggedPlayers.join(', '))+'</div>' : '';
return '<div style="background:var(--table-bg); border-radius:8px; padding:8px;">'
+ img
+ '<div style="margin-top:6px; font-size:14px;">by <b>@'+esc(p.takenByName)+'</b></div>'
+ '<div style="opacity:.6; font-size:12px;">room '+esc(p.roomId)+' &middot; '+esc(when)+' &middot; '+esc(p.cheers)+' cheers</div>'
+ tags
+ '<button onclick="photoDelete('+esc(p.id)+')" style="margin-top:6px; width:100%; background:#c0392b; color:white; border:none; padding:6px; border-radius:5px; cursor:pointer;">Delete</button>'
+ '</div>';
}).join('');
})
.catch(err => showPopup('Error!', 'Load failed: ' + err));
}
function photoDelete(id) {
if (!confirm('Delete photo ' + id + '? This removes the image and cannot be undone.')) return;
const form = new URLSearchParams(); form.append('id', id);
fetch('/admin/api/delete_photo', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: form.toString()
})
.then(r => r.json())
.then(data => { showPopup(data.success ? 'Deleted' : 'Error!', data.message || 'Failed.'); if (data.success) photosLoad(); })
.catch(err => showPopup('Error!', 'Delete failed: ' + err));
}
// ---- Reports ---------------------------------------------------------
// The queue behind the in-game report menu. Every row carries the actions at the
// bottom of it, so handling a report never means going and finding the player in
// another tab first.
let _reportsCache = [];
function reportsLoad() {
const all = document.getElementById('reportsShowAll').checked ? '1' : '';
const pid = (document.getElementById('reportsPlayerFilter').value || '').trim();
let url = '/admin/api/reports?take=200';
if (all) url += '&all=1';
if (pid) url += '&playerId=' + encodeURIComponent(pid);
fetch(url, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const list = document.getElementById('reportsList');
const sum = document.getElementById('reportsSummary');
if (!data.success) { list.innerHTML = ''; sum.textContent = data.message || 'Failed.'; return; }
_reportsCache = data.reports || [];
reportsBadge(data.openCount || 0);
sum.textContent = _reportsCache.length + ' report(s) shown, ' + (data.openCount || 0) + ' open.';
if (_reportsCache.length === 0) { list.innerHTML = '<span style="opacity:.7;">Nothing to review.</span>'; return; }
list.innerHTML = _reportsCache.map(r => {
const when = (r.createdAt || '').replace('T', ' ').slice(0, 16);
const room = r.source === 'website'
? ('<span style="background:#8e44ad; color:#fff; border-radius:9px; padding:1px 8px; font-size:12px;">website</span> '
+ (r.subjectLink ? '<a href="' + esc(r.subjectLink) + '" target="_blank" style="color:#9ecbff;">' + esc(r.subject || 'item') + (r.subjectId ? ' #' + esc(r.subjectId) : '') + '</a>' : esc(r.subject || '')))
: (r.roomId ? ('room ' + esc(r.roomId) + (r.roomName ? ' (' + esc(r.roomName) + ')' : '')) : 'no room');
const repeat = r.priorReports > 1
? '<span style="background:#c0392b; color:#fff; border-radius:9px; padding:1px 8px; font-size:12px; margin-left:8px;">' + esc(r.priorReports) + ' reports total</span>' : '';
const banned = r.reportedIsBanned
? '<span style="background:#7f8c8d; color:#fff; border-radius:9px; padding:1px 8px; font-size:12px; margin-left:6px;">already banned</span>' : '';
const state = r.resolved
? '<div style="opacity:.7; font-size:13px; margin-top:6px;">Resolved: <b>' + esc(r.action || '') + '</b> by ' + esc(r.resolvedByName || '') + (r.moderatorNote ? ' &mdash; ' + esc(r.moderatorNote) : '') + '</div>'
: '';
// Actions live at the BOTTOM of the report they act on.
const actions = r.resolved ? '' :
'<div style="margin-top:12px; padding-top:12px; border-top:1px solid rgba(255,255,255,.12); display:flex; gap:8px; align-items:center; flex-wrap:wrap;">'
+ '<input type="text" id="rnote' + r.id + '" placeholder="Note / message shown to the player" style="flex:1; min-width:220px; padding:8px; border:1px solid #ccc; border-radius:5px; box-sizing:border-box;" />'
+ '<select id="rdur' + r.id + '" style="padding:8px; border-radius:5px;">'
+ '<option value="60">Ban 1 hour</option>'
+ '<option value="1440">Ban 1 day</option>'
+ '<option value="10080">Ban 7 days</option>'
+ '<option value="43200">Ban 30 days</option>'
+ '<option value="0">Ban permanently</option>'
+ '</select>'
+ '<button onclick="reportAct(' + r.id + ',\'ban\')" style="background:#c0392b; color:#fff; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Ban</button>'
+ '<button onclick="reportAct(' + r.id + ',\'warn\')" style="background:#d68910; color:#fff; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Warn</button>'
+ '<button onclick="reportAct(' + r.id + ',\'dismiss\')" style="background:#555; color:#fff; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Dismiss</button>'
+ '<button onclick="viewProfile(' + r.reportedPlayerId + ')" style="background:#2c3e50; color:#fff; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Profile</button>'
+ '</div>';
return '<div style="background:var(--table-bg); border-radius:8px; padding:14px;' + (r.resolved ? ' opacity:.6;' : '') + '">'
+ '<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">'
+ '<div style="min-width:0;">'
+ '<div style="font-size:15px;"><b>' + esc(r.reportedName) + '</b> <span style="opacity:.6;">#' + esc(r.reportedPlayerId) + '</span>' + repeat + banned + '</div>'
+ '<div style="opacity:.75; font-size:13px; margin-top:3px;">reported by <b>' + esc(r.reporterName) + '</b> &middot; ' + esc(r.categoryName) + ' &middot; ' + room + ' &middot; ' + esc(when) + '</div>'
+ '</div>'
+ '<div style="opacity:.5; font-size:12px;">#' + esc(r.id) + '</div>'
+ '</div>'
+ (r.details ? '<div style="margin-top:10px; background:rgba(0,0,0,.18); border-radius:6px; padding:10px; white-space:pre-wrap; word-break:break-word;">' + esc(r.details) + '</div>' : '<div style="margin-top:8px; opacity:.5; font-size:13px;">No description given.</div>')
+ state
+ actions
+ '</div>';
}).join('');
})
.catch(err => showPopup('Error!', 'Load failed: ' + err));
}
function reportAct(id, action) {
const note = (document.getElementById('rnote' + id) || {}).value || '';
const dur = (document.getElementById('rdur' + id) || {}).value || '0';
if (action === 'ban') {
const label = dur === '0' ? 'PERMANENTLY' : ('for ' + dur + ' minutes');
if (!confirm('Ban this player ' + label + '?')) return;
}
const form = new URLSearchParams();
form.append('reportId', id);
form.append('action', action);
form.append('note', note);
form.append('duration', dur);
fetch('/admin/api/report_action', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString()
})
.then(r => r.json())
.then(data => { showPopup(data.success ? 'Done' : 'Error!', data.message || 'Failed.'); if (data.success) reportsLoad(); })
.catch(err => showPopup('Error!', 'Action failed: ' + err));
}
// Unhandled-report count on the tab, so the queue is visible without opening it.
function reportsBadge(n) {
const b = document.getElementById('reportsBadge');
if (!b) return;
if (n > 0) { b.textContent = n; b.style.display = 'inline-block'; }
else b.style.display = 'none';
}
function reportsPoll() {
fetch('/admin/api/reports?take=1', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => { if (d && d.success) reportsBadge(d.openCount || 0); })
.catch(() => {});
}
// ---- Staff / vmods ---------------------------------------------------
function staffLoad() {
fetch('/admin/api/staff', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const list = document.getElementById('staffList');
const sum = document.getElementById('staffSummary');
if (!data.success) { list.innerHTML = ''; sum.textContent = data.message || 'Failed.'; return; }
const rows = data.staff || [];
sum.textContent = rows.length + ' account(s) hold a staff flag.';
if (rows.length === 0) { list.innerHTML = '<span style="opacity:.7;">Nobody has a staff flag yet.</span>'; return; }
list.innerHTML = rows.map(p => {
const tag = (on, label, colour) => on
? '<span style="background:' + colour + '; color:#fff; border-radius:9px; padding:1px 8px; font-size:12px; margin-right:5px;">' + label + '</span>' : '';
return '<div style="background:var(--table-bg); border-radius:8px; padding:12px; display:flex; justify-content:space-between; gap:12px; align-items:center; flex-wrap:wrap;">'
+ '<div><b>' + esc(p.displayName || p.username || '') + '</b> <span style="opacity:.6;">@' + esc(p.username || '') + ' &middot; #' + esc(p.accountId) + '</span></div>'
+ '<div>'
+ tag(p.isModerator, 'moderator', '#2980b9')
+ tag(p.isDeveloper, 'developer', '#8e44ad')
+ tag(p.isScreenshare, 'screenshare', '#16a085')
+ tag(p.isSupport, 'support', '#d68910')
+ '<button onclick="staffPick(' + p.accountId + ')" style="background:#555; color:#fff; border:none; padding:6px 12px; border-radius:5px; cursor:pointer; margin-left:6px;">Edit</button>'
+ '</div>'
+ '</div>';
}).join('');
})
.catch(err => showPopup('Error!', 'Load failed: ' + err));
}
function staffPick(id) {
document.getElementById('staffPlayerId').value = id;
staffLoadOne();
}
// Reads the CURRENT flags into the boxes first. Without this the form would post
// whatever the boxes happened to be showing and quietly strip a flag nobody meant
// to touch.
function staffLoadOne() {
const id = (document.getElementById('staffPlayerId').value || '').trim();
if (!id) { showPopup('Error!', 'Enter a player id first.'); return; }
fetch('/admin/api/staff', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const p = (data.staff || []).find(x => String(x.accountId) === String(id));
document.getElementById('staffModerator').checked = !!(p && p.isModerator);
document.getElementById('staffDeveloper').checked = !!(p && p.isDeveloper);
document.getElementById('staffScreenshare').checked = !!(p && p.isScreenshare);
document.getElementById('staffSupport').checked = !!(p && p.isSupport);
if (!p) showPopup('Loaded', 'That account holds no staff flags yet.');
})
.catch(err => showPopup('Error!', 'Load failed: ' + err));
}
function staffApply() {
const id = (document.getElementById('staffPlayerId').value || '').trim();
if (!id) { showPopup('Error!', 'Enter a player id first.'); return; }
const form = new URLSearchParams();
form.append('playerId', id);
form.append('moderator', document.getElementById('staffModerator').checked ? 'true' : 'false');
form.append('developer', document.getElementById('staffDeveloper').checked ? 'true' : 'false');
form.append('screenshare', document.getElementById('staffScreenshare').checked ? 'true' : 'false');
form.append('support', document.getElementById('staffSupport').checked ? 'true' : 'false');
fetch('/admin/api/set_roles', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString()
})
.then(r => r.json())
.then(data => { showPopup(data.success ? 'Saved' : 'Error!', data.message || 'Failed.'); if (data.success) staffLoad(); })
.catch(err => showPopup('Error!', 'Save failed: ' + err));
}
function commentsLoad() {
fetch('/admin/api/room_comments', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const list = document.getElementById('commentsList');
const sum = document.getElementById('commentsSummary');
if (!data.success) { list.innerHTML=''; sum.textContent = data.message || 'Failed.'; return; }
sum.textContent = data.count + ' comment(s).';
const items = data.comments || [];
if (items.length === 0) { list.innerHTML = '<span style="opacity:.7;">No comments.</span>'; return; }
list.innerHTML = items.map(c => {
const when = (c.createdAt || '').replace('T',' ').slice(0,16);
return '<div style="background:var(--table-bg); border-radius:8px; padding:12px; display:flex; justify-content:space-between; gap:12px; align-items:flex-start;">'
+ '<div style="min-width:0;">'
+ '<div style="font-size:15px; word-break:break-word;">'+esc(c.message)+'</div>'
+ '<div style="opacity:.6; font-size:12px; margin-top:4px;">by <b>@'+esc(c.authorName)+'</b> &middot; room '+esc(c.roomId)+' &middot; sub '+esc(c.subRoomId)+' &middot; '+esc(when)+'</div>'
+ '</div>'
+ '<button onclick="commentDelete('+esc(c.id)+')" style="flex:0 0 auto; background:#c0392b; color:white; border:none; padding:6px 12px; border-radius:5px; cursor:pointer;">Delete</button>'
+ '</div>';
}).join('');
})
.catch(err => showPopup('Error!', 'Load failed: ' + err));
}
function commentDelete(id) {
if (!confirm('Delete comment ' + id + '? This cannot be undone.')) return;
const form = new URLSearchParams(); form.append('id', id);
fetch('/admin/api/delete_room_comment', {
method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: form.toString()
})
.then(r => r.json())
.then(data => { showPopup(data.success ? 'Deleted' : 'Error!', data.message || 'Failed.'); if (data.success) commentsLoad(); })
.catch(err => showPopup('Error!', 'Delete failed: ' + err));
}
// Live search. Typing schedules a lookup 220 ms after the last keystroke, so a word costs
// one request instead of one per letter. _giftSearchSeq drops any answer that arrives after
// a newer one has already been sent - without it a slow early request can land last and
// repaint the list with results for a query the box no longer holds.
let _giftSearchTimer = null;
let _giftSearchSeq = 0;
function giftSearchLive() {
if (_giftSearchTimer) clearTimeout(_giftSearchTimer);
_giftSearchTimer = setTimeout(giftSearch, 220);
}
function giftSearch() {
if (_giftSearchTimer) { clearTimeout(_giftSearchTimer); _giftSearchTimer = null; }
const q = document.getElementById('giftSearchBox').value.trim();
const sum = document.getElementById('giftSummary');
const list = document.getElementById('giftResults');
if (!q) { sum.textContent = 'Start typing to search every storefront and the whole avatar catalog.'; list.innerHTML = ''; return; }
const seq = ++_giftSearchSeq;
sum.textContent = 'Searching\u2026';
fetch('/admin/api/gift/search?q=' + encodeURIComponent(q), { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
if (seq !== _giftSearchSeq) return; // a newer search has already been sent
if (!data.success) { sum.textContent = data.message || 'Failed.'; list.innerHTML = ''; return; }
const items = data.items || [];
const total = (typeof data.total === 'number') ? data.total : data.count;
sum.textContent = total > items.length
? ('showing ' + items.length + ' of ' + total + ' match(es) \u2014 keep typing to narrow it down')
: (items.length + ' match(es).');
if (items.length === 0) { list.innerHTML = '<span style="opacity:.7;">No items.</span>'; return; }
list.innerHTML = items.map(it => {
// A storefront drop is identified by its PurchasableItemId; a catalog-only
// item has no such id and is identified by its descriptor instead.
const fromStore = it.source === 'store';
const tag = fromStore
? '<span style="background:#2980b9; color:#fff; border-radius:9px; padding:1px 7px; font-size:11px; margin-left:6px;">store</span>'
: '<span style="background:#7f8c8d; color:#fff; border-radius:9px; padding:1px 7px; font-size:11px; margin-left:6px;">catalog</span>';
const meta = fromStore
? ('id ' + esc(it.purchasableItemId) + ' &middot; store ' + esc(it.storefrontType) + ' &middot; drop ' + esc(it.giftDropId) + ' &middot; rarity ' + esc(it.rarity))
: (esc(it.avatarItemDesc) + ' &middot; rarity ' + esc(it.rarity));
const args = "'" + String(it.avatarItemDesc || '').replace(/'/g, "\\'") + "'";
return '<div style="background:var(--table-bg); border-radius:8px; padding:10px 12px; display:flex; justify-content:space-between; gap:12px; align-items:center;">'
+ '<div style="min-width:0;"><b>' + esc(it.name) + '</b>' + tag
+ '<div style="opacity:.6; font-size:12px; word-break:break-all;">' + meta + '</div></div>'
+ '<button onclick="giftGive(' + esc(it.purchasableItemId) + ',' + esc(it.storefrontType) + ',this,' + args + ')" style="flex:0 0 auto; background:var(--card-hover-bg); color:white; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Give</button>'
+ '</div>';
}).join('');
})
.catch(err => { if (seq === _giftSearchSeq) sum.textContent = 'Search failed: ' + err; });
}
function giftGive(itemId, storefront, btn, avatarItemDesc) {
const pid = document.getElementById('giftPlayerId').value.trim();
if (!pid) { showPopup('Pick a player', 'Enter a player id (or -1 for everyone) at the top.'); return; }
const mode = document.getElementById('giftMode').value;
const xp = document.getElementById('giftXp').value || '0';
const message = document.getElementById('giftMessage').value || '';
if (pid === '-1' && !confirm('Give this item to EVERY player? (owners are skipped)')) return;
if (btn) { btn.disabled = true; btn.textContent = '...'; }
const form = new URLSearchParams();
form.append('playerId', pid); form.append('purchasableItemId', itemId);
form.append('storefrontType', storefront); form.append('mode', mode);
form.append('avatarItemDesc', avatarItemDesc || '');
form.append('xp', xp); form.append('message', message);
fetch('/admin/api/gift/give', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString()
})
.then(r => r.json())
.then(data => { showPopup(data.success ? 'Sent' : 'Error!', data.message || 'Failed.'); })
.catch(err => showPopup('Error!', 'Give failed: ' + err))
.finally(() => { if (btn) { btn.disabled = false; btn.textContent = 'Give'; } });
}
function eventsLoad() {
fetch('/admin/api/events', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const body = document.getElementById('eventsRows');
if (!data.success) {
body.innerHTML = '<tr><td colspan="7" style="opacity:.8;">' + esc(data.message || 'Failed to load.') + '</td></tr>';
return;
}
const rows = data.events || [];
if (rows.length === 0) {
body.innerHTML = '<tr><td colspan="7" style="opacity:.7;">No events.</td></tr>';
return;
}
body.innerHTML = rows.map(e =>
'<tr>' +
'<td>' + esc(e.PlayerEventId) + '</td>' +
'<td>' + esc(e.Name) + '<br><span style="opacity:.6; font-size:13px;">' + esc(e.Description) + '</span></td>' +
'<td>' + esc(e.CreatorPlayerId) + '</td>' +
'<td>' + esc(e.RoomId) + '</td>' +
'<td>' + esc((e.StartTime || '').replace('T', ' ').slice(0, 16)) + '</td>' +
'<td>' + esc(e.AttendeeCount) + '</td>' +
'<td><button onclick="eventDelete(' + esc(e.PlayerEventId) + ')" style="background:#c0392b; color:white; border:none; padding:6px 12px; border-radius:5px; cursor:pointer;">Delete</button></td>' +
'</tr>').join('');
})
.catch(err => showPopup('Error!', 'Load failed: ' + err));
}
function eventDelete(id) {
if (!confirm('Delete event ' + id + '? This cannot be undone.')) return;
const form = new URLSearchParams();
form.append('eventId', id);
fetch('/admin/api/delete_event', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Deleted' : 'Error!', data.message || (data.success ? 'Done.' : 'Delete failed.'));
if (data.success) eventsLoad();
})
.catch(err => showPopup('Error!', 'Delete failed: ' + err));
}
function edit_player() {
const id = editId();
if (!id) return;
const form = new URLSearchParams();
form.append('playerId', id);
form.append('username', document.getElementById('editusername').value.trim());
form.append('displayName', document.getElementById('editdisplayname').value.trim());
form.append('profileImage', document.getElementById('editprofileimage').value.trim());
form.append('bannerImage', document.getElementById('editbannerimage').value.trim());
form.append('steamId', document.getElementById('editsteamid').value.trim());
form.append('discordId', document.getElementById('editdiscordid').value.trim());
fetch('/admin/api/edit_player', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Saved' : 'Error!', data.message || (data.success ? 'Done.' : 'Save failed.'));
if (data.success) editLoad();
})
.catch(err => showPopup('Error!', 'Save failed: ' + err));
}
// Fill the box with a readable random password (no ambiguous 0/O/1/l/I).
function genPassword() {
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789';
let out = '';
const rnd = new Uint32Array(12);
crypto.getRandomValues(rnd);
for (let i = 0; i < 12; i++) out += chars[rnd[i] % chars.length];
document.getElementById('editnewpassword').value = out;
}
function reset_password() {
const id = editId();
if (!id) return;
const pw = (document.getElementById('editnewpassword').value || '').trim();
if (pw.length < 4) { showPopup('Error!', 'Password must be at least 4 characters.'); return; }
if (pw.length > 64) { showPopup('Error!', 'Password must be fewer than 64 characters.'); return; }
const form = new URLSearchParams();
form.append('playerId', id);
form.append('newPassword', pw);
fetch('/admin/api/reset_password', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Password reset' : 'Error!', data.message || (data.success ? 'Done.' : 'Reset failed.'));
if (data.success) document.getElementById('editnewpassword').value = '';
})
.catch(err => showPopup('Error!', 'Reset failed: ' + err));
}
// ---- Danger zone: expire ALL in-game bearer tokens ----------------
function revokeGameTokens() {
if (!confirm('Expire ALL in-game sign-ins?\n\nEvery player is signed out of the GAME and must log in again. Website and admin logins are NOT affected.')) return;
const el = document.getElementById('revokeState');
if (el) el.textContent = 'Expiring…';
fetch('/admin/api/revoke_game_tokens', { method: 'POST', credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'In-game tokens expired' : 'Error!',
data.message || (data.success ? 'Done.' : 'Failed.'));
if (el) el.textContent = data.success
? (data.kicked + ' connected player(s) were signed out just now.')
: '';
})
.catch(err => { showPopup('Error!', 'Failed: ' + err); if (el) el.textContent = ''; });
}
// ---- Featured Rooms -----------------------------------------------
function loadFeaturedRooms() {
fetch('/admin/api/featured_rooms', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const box = document.getElementById('featuredCurrent');
const ta = document.getElementById('featuredIds');
const rooms = (data && data.rooms) || [];
if (!rooms.length) {
box.innerHTML = '<span style="opacity:.7;">No rooms featured.</span>';
if (ta) ta.value = '';
return;
}
box.innerHTML = 'Currently featured: ' + rooms.map(r =>
'<b>' + esc(r.name) + '</b> <span style="opacity:.6;">(' + r.id + ')</span>' +
(r.exists ? '' : ' <span style="color:#e74c3c;">missing</span>')).join(' &nbsp;|&nbsp; ');
if (ta) ta.value = rooms.map(r => r.id).join(', ');
})
.catch(err => { document.getElementById('featuredCurrent').textContent = 'Could not load: ' + err; });
}
function saveFeaturedRooms() {
const form = new URLSearchParams();
form.append('roomIds', document.getElementById('featuredIds').value.trim());
fetch('/admin/api/set_featured_rooms', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Saved' : 'Error!', data.message || (data.success ? 'Done.' : 'Save failed.'));
if (data.success) loadFeaturedRooms();
})
.catch(err => showPopup('Error!', 'Save failed: ' + err));
}
// Populate on load so the Rooms tab shows the current featured set.
loadFeaturedRooms();
// ---- Featured players (Featured tab) --------------------------------
function loadFeaturedPlayers() {
fetch('/admin/api/featured', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const box = document.getElementById('featuredPlayersCurrent');
const ta = document.getElementById('featuredPlayerIds');
if (!data.success) { box.textContent = data.message || 'Failed.'; return; }
const ps = data.players || [];
const eff = data.effectiveCreator || {};
box.innerHTML = (ps.length
? 'Currently featured: ' + ps.map(p => '<b>@' + esc(p.username) + '</b> <span style="opacity:.6;">(' + esc(p.id) + ')</span>' + (p.exists ? '' : ' <span style="color:#e74c3c;">missing</span>')).join(' &nbsp;|&nbsp; ')
: '<span style="opacity:.7;">No players featured - falling back to the most active creator.</span>')
+ '<div style="opacity:.7; margin-top:4px;">Featured Creator shown in-game right now: <b>@' + esc(eff.username || '?') + '</b> (' + esc(eff.id || '0') + ')</div>';
if (ta) ta.value = ps.map(p => p.id).join(', ');
document.getElementById('featuredAnnouncement').value = data.announcement || '';
document.getElementById('featuredAnnouncementUrl').value = data.announcementUrl || '';
})
.catch(err => { document.getElementById('featuredPlayersCurrent').textContent = 'Could not load: ' + err; });
}
function saveFeaturedPlayers() {
const form = new URLSearchParams();
form.append('playerIds', document.getElementById('featuredPlayerIds').value.trim());
form.append('announcement', document.getElementById('featuredAnnouncement').value.trim());
form.append('announcementUrl', document.getElementById('featuredAnnouncementUrl').value.trim());
fetch('/admin/api/set_featured_players', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Saved' : 'Error!', data.message || (data.success ? 'Done.' : 'Save failed.'));
if (data.success) loadFeaturedPlayers();
})
.catch(err => showPopup('Error!', 'Save failed: ' + err));
}
// ---- Bug reports ---------------------------------------------------
function bugsLoad() {
const all = document.getElementById('bugsShowAll').checked ? '?all=1' : '';
fetch('/admin/api/bugreports' + all, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const list = document.getElementById('bugsList');
const sum = document.getElementById('bugsSummary');
if (!data.success) { list.innerHTML = ''; sum.textContent = data.message || 'Failed.'; return; }
const rows = data.reports || [];
sum.textContent = rows.length + ' bug report(s) shown, ' + (data.openCount || 0) + ' open.';
if (rows.length === 0) { list.innerHTML = '<span style="opacity:.7;">No bug reports.</span>'; return; }
list.innerHTML = rows.map(r => {
const when = (r.at || '').replace('T', ' ').slice(0, 16);
const files = (r.files || []).map(f => '<a href="/admin/api/bugreports/file/' + encodeURIComponent(f) + '" target="_blank" style="margin-right:8px;">' + esc(f) + '</a>').join('');
const state = r.resolved ? '<div style="opacity:.7; font-size:13px; margin-top:6px;">Resolved by ' + esc(r.resolvedBy || '') + (r.note ? ' &mdash; ' + esc(r.note) : '') + '</div>' : '';
const act = r.resolved
? '<button onclick="bugResolve(' + r.id + ',1)" style="background:#555; color:#fff; border:none; padding:6px 12px; border-radius:5px; cursor:pointer;">Reopen</button>'
: '<input type="text" id="bugnote' + r.id + '" placeholder="Note" style="padding:6px; border:1px solid #ccc; border-radius:5px;" /> '
+ '<button onclick="bugResolve(' + r.id + ',0)" style="background:#27ae60; color:#fff; border:none; padding:6px 12px; border-radius:5px; cursor:pointer;">Resolve</button>';
return '<div style="background:var(--table-bg); border-radius:8px; padding:14px;' + (r.resolved ? ' opacity:.6;' : '') + '">'
+ '<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">'
+ '<div><b>' + esc(r.title || '(no title)') + '</b> <span style="opacity:.6;">by @' + esc(r.username) + ' #' + esc(r.accountId) + (r.room ? ' &middot; ' + esc(r.room) : '') + (r.category ? ' &middot; ' + esc(r.category) : '') + ' &middot; ' + esc(when) + '</span></div>'
+ '<div style="opacity:.5; font-size:12px;">#' + r.id + '</div>'
+ '</div>'
+ (r.description ? '<div style="margin-top:8px; background:rgba(0,0,0,.18); border-radius:6px; padding:10px; white-space:pre-wrap; word-break:break-word;">' + esc(r.description) + '</div>' : '')
+ (files ? '<div style="margin-top:8px;">Files: ' + files + '</div>' : '')
+ '<details style="margin-top:8px; opacity:.8;"><summary>raw</summary><pre style="white-space:pre-wrap; word-break:break-word; font-size:12px;">' + esc(r.raw || '') + '</pre></details>'
+ state
+ '<div style="margin-top:10px;">' + act + '</div>'
+ '</div>';
}).join('');
})
.catch(err => showPopup('Error!', 'Load failed: ' + err));
}
function bugResolve(id, reopen) {
const form = new URLSearchParams();
form.append('id', id);
form.append('reopen', reopen ? '1' : '0');
const note = document.getElementById('bugnote' + id);
form.append('note', note ? note.value : '');
fetch('/admin/api/bugreports/resolve', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString() })
.then(r => r.json()).then(d => { if (!d.success) showPopup('Error!', d.message || 'Failed.'); bugsLoad(); })
.catch(err => showPopup('Error!', 'Failed: ' + err));
}
// ---- Creator earnings ------------------------------------------------
function earningsLoad() {
fetch('/admin/api/earnings', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const list = document.getElementById('earningsList');
const sum = document.getElementById('earningsSummary');
if (!data.success) { sum.textContent = data.message || 'Failed.'; return; }
const rows = data.earnings || [];
sum.textContent = rows.length + ' sale(s), ' + (data.pending || 0) + ' pending.';
if (rows.length === 0) { list.innerHTML = '<span style="opacity:.7;">No sales yet.</span>'; return; }
list.innerHTML = '<table style="width:100%; border-collapse:collapse;">'
+ '<tr style="opacity:.7; text-align:left;"><th>When</th><th>Seller</th><th>Buyer</th><th>Item</th><th>Spent</th><th>Share</th><th>Pays out</th><th>Status</th></tr>'
+ rows.map(e => '<tr>'
+ '<td>' + esc((e.boughtAt || '').replace('T', ' ').slice(0, 16)) + '</td>'
+ '<td>@' + esc(e.sellerName) + ' <span style="opacity:.6;">#' + esc(e.seller) + '</span></td>'
+ '<td>@' + esc(e.buyerName) + '</td>'
+ '<td>' + esc(e.kind) + ': ' + esc(e.item) + '</td>'
+ '<td>' + e.spent + '</td><td><b>' + e.share + '</b></td>'
+ '<td>' + esc((e.payoutAt || '').slice(0, 10)) + '</td>'
+ '<td>' + (e.paidAt ? 'paid ' + esc(e.paidAt.slice(0, 10)) : 'pending') + '</td>'
+ '</tr>').join('')
+ '</table>';
})
.catch(err => { document.getElementById('earningsSummary').textContent = 'Could not load: ' + err; });
}
function earningsPayNow() {
fetch('/admin/api/earnings/paynow', { method: 'POST', credentials: 'same-origin' })
.then(r => r.json()).then(d => { showPopup(d.success ? 'Done' : 'Error!', d.message || ''); earningsLoad(); })
.catch(err => showPopup('Error!', 'Failed: ' + err));
}
// ---- Economy tab ---------------------------------------------------
// Same idiom as the other admin actions: POST form-urlencoded with
// credentials, expect {success, message}, report via showPopup.
function econId() {
const raw = (document.getElementById('econPlayerId').value || '').trim();
if (!raw) {
showPopup('Error!', 'Enter a player id first.');
return null;
}
return raw;
}
function econPost(url, form, okTitle) {
return fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? okTitle : 'Error!', data.message || (data.success ? 'Done.' : 'Failed.'));
if (data.success) econLoad();
return data;
})
.catch(err => showPopup('Error!', 'Request failed: ' + err));
}
function econLoad() {
const id = econId();
if (!id) return;
fetch('/admin/api/get_economy?playerId=' + encodeURIComponent(id), { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const box = document.getElementById('econSummary');
if (!data.success) {
box.innerHTML = '<span style="opacity:.8;">' + (data.message || 'Not found.') + '</span>';
return;
}
let html = '<b>@' + data.username + '</b> (id ' + data.playerId + ')<br>'
+ 'XP: <b>' + data.xp + '</b> &nbsp; Level: <b>' + data.level + '</b>';
if (data.balances && data.balances.length) {
html += '<br><br><b>Balances</b><br>';
data.balances.forEach(b => {
html += '&nbsp;&nbsp;' + b.Name + ': <b>' + b.Amount + '</b><br>';
});
} else {
html += '<br><br><span style="opacity:.75;">No balances yet.</span>';
}
box.innerHTML = html;
})
.catch(err => showPopup('Error!', 'Lookup failed: ' + err));
}
function refreshNewAcctTest() {
fetch('/admin/api/test_newaccount', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => { const el = document.getElementById('newAcctState'); if (el) el.textContent = 'state: ' + (d.enabled ? 'ON' : 'OFF') + ' - ' + (d.message || ''); })
.catch(() => {});
}
function setNewAcctTest(on) {
const form = new URLSearchParams(); form.append('on', on);
econPost('/admin/api/test_newaccount', form, on ? 'Test switch ON' : 'Test switch OFF');
setTimeout(refreshNewAcctTest, 500);
}
setTimeout(refreshNewAcctTest, 800);
function econGrantBalance() {
const id = econId();
if (!id) return;
const amount = document.getElementById('econAmount').value.trim();
if (!amount) {
showPopup('Error!', 'Enter an amount.');
return;
}
const form = new URLSearchParams();
form.append('playerId', id);
form.append('currencyType', document.getElementById('econCurrency').value);
form.append('amount', amount);
form.append('message', (document.getElementById('econBoxMessage').value || '').trim());
form.append('giftContext', document.getElementById('econBoxType').value);
econPost('/admin/api/grant_balance', form, 'Balance updated');
}
// "Send to all" - the same request with playerId = -1, which every one of these admin
// endpoints reads as "every account" (the convention /admin/api/gift/give already used).
// Confirmed first, because it is not undoable and it touches every account on the server.
function econConfirmAll(what) {
return confirm('Send ' + what + ' to EVERY account on the server?\n\nThis cannot be undone.');
}
function econGrantBalanceAll() {
const amount = document.getElementById('econAmount').value.trim();
if (!amount) { showPopup('Error!', 'Enter an amount.'); return; }
const curSel = document.getElementById('econCurrency');
const what = amount + ' ' + curSel.options[curSel.selectedIndex].text;
if (!econConfirmAll(what)) return;
const form = new URLSearchParams();
form.append('playerId', '-1');
form.append('currencyType', curSel.value);
form.append('amount', amount);
form.append('message', (document.getElementById('econBoxMessage').value || '').trim());
form.append('giftContext', document.getElementById('econBoxType').value);
econPost('/admin/api/grant_balance', form, 'Sent to everyone');
}
function econGiftBoxAll() {
const sel = document.getElementById('econGiftBox');
if (!econConfirmAll('the "' + sel.options[sel.selectedIndex].text + '" reward picker')) return;
const form = new URLSearchParams();
form.append('playerId', '-1');
form.append('giftContext', sel.value);
form.append('message', document.getElementById('econGiftMessage').value.trim());
econPost('/admin/api/send_gamereward', form, 'Sent to everyone');
}
function econGiftBox() {
const id = econId();
if (!id) return;
const sel = document.getElementById('econGiftBox');
const form = new URLSearchParams();
form.append('playerId', id);
form.append('giftContext', sel.value);
form.append('message', document.getElementById('econGiftMessage').value.trim());
econPost('/admin/api/send_gamereward', form, 'Reward sent');
}
// ---- Unban ---------------------------------------------------------
// Clears the account ban AND every device/IP ban that came from this account.
// Lifting only the account block would leave their device blacklisted, so the
// player still could not log in and the unban would look broken.
function unban_player(accountId, username) {
if (!confirm(`Unban @${username} (id ${accountId})?\n\nThis clears their account ban and any device/IP bans that came from this account.`)) {
return;
}
const form = new URLSearchParams();
form.append("playerId", accountId);
form.append("liftDeviceIp", "1");
fetch("/admin/api/unban_player", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString()
})
.then(r => r.json())
.then(data => {
showPopup(data.success ? "Unbanned" : "Error!", data.message || (data.success ? "Done." : "Unban failed."));
if (data.success) closePopup('player-popup');
})
.catch(err => showPopup("Error!", "Unban failed: " + err));
}
function showPopup(title, text, iconUrl) {
const popup = document.getElementById('alert-popup');
document.getElementById('popup-title').textContent = title;
document.getElementById('popup-text').innerHTML = text;
const icon = document.getElementById('popup-icon');
icon.src = iconUrl;
icon.style.display = iconUrl ? 'block' : 'none';
popup.classList.add('active');
}
async function showLoading() {
const popup = document.getElementById('alert-popup');
document.getElementById('popup-title').textContent = 'Loading...';
document.getElementById('popup-text').textContent = '';
const icon = document.getElementById('popup-icon');
icon.src = 'data:image/png;base64,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';
icon.style.display = 'block';
icon.classList.add('spinning');
popup.classList.add('active');
}
function showConfirm(title, text, onConfirmFunction) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-text').textContent = text;
const confirmButton = document.getElementById('confirm-action');
confirmButton.onclick = () => {
onConfirmFunction();
closePopup('confirm-popup');
};
const confirmPopup = document.getElementById('confirm-popup');
confirmPopup.classList.add('active');
}
function closePopup(popupId) {
const popup = document.getElementById(popupId);
popup.classList.remove('active');
}
function closeonlineplayers() {
const popup = document.getElementById("onlinePlayersPopup");
popup.style.display = "none";
}
function getTotalDurationSeconds() {
const days = parseInt(document.getElementById("durDays").value) || 0;
const hours = parseInt(document.getElementById("durHours").value) || 0;
const minutes = parseInt(document.getElementById("durMinutes").value) || 0;
const seconds = parseInt(document.getElementById("durSeconds").value) || 0;
return (
(days * 24 * 60 * 60) +
(hours * 60 * 60) +
(minutes * 60) +
seconds
);
}
async function viewOnlinePlayers() {
try {
const response = await fetch("/admin/api/who_is_online");
const playerIds = await response.json();
if (playerIds.length === 0) {
showPopup("Warning!", "There are no players online.");
return;
}
const queryString = playerIds.map(id => `id=${encodeURIComponent(id)}`).join("&");
const playerResponse = await fetch(`/admin/api/account/bulk?${queryString}`);
const playersData = await playerResponse.json();
let popupContent = "";
const heartbeatPromises = playersData.map(async (playerData) => {
if (!playerData) return;
const heartbeatResponse = await fetch(`/admin/api/heartbeat_data/${playerData.accountId}/name`);
const heartbeatData = await heartbeatResponse.text();
popupContent += `<b>(${playerData.accountId}) ${playerData.username} - is currently in ${heartbeatData}</b><br>`;
});
await Promise.all(heartbeatPromises);
showPopup("Online Players", popupContent);
} catch (error) {
console.error(error);
showPopup("Error!", "Failed to get online players.");
}
}
function LogoutFromAdminPage() {
GoToPage("/admin/logout");
}
function GoToPage(data_tab) {
closePopup('confirm-popup');
showLoading();
const url = data_tab;
new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 200);
}).then(() => {
window.location.href = data_tab;
});
}
const INT32_MAX = 2147483647;
const unitMultipliers = {
seconds: 1,
minutes: 60,
hours: 3600,
days: 86400,
years: 31536000
};
function updateBanUI() {
const amountInput = document.getElementById("banAmount");
const unit = document.getElementById("banUnit").value;
const preview = document.getElementById("banPreview");
if (unit === "permanent") {
amountInput.value = "";
amountInput.disabled = true;
preview.textContent = "This ban is permanent.";
return;
}
amountInput.disabled = false;
const multiplier = unitMultipliers[unit];
const maxAmount = Math.floor(INT32_MAX / multiplier);
amountInput.max = maxAmount;
if (parseInt(amountInput.value, 10) > maxAmount) {
amountInput.value = maxAmount;
}
if (amountInput.value) {
preview.textContent = `Ban duration: ${amountInput.value} ${unit}`;
} else {
preview.textContent = "";
}
}
function getBanDurationSeconds() {
const unit = document.getElementById("banUnit").value;
if (unit === "permanent") {
return -1;
}
const amount = parseInt(document.getElementById("banAmount").value, 10);
if (isNaN(amount) || amount <= 0) {
return 0;
}
return Math.min(
amount * unitMultipliers[unit],
INT32_MAX
);
}
document.getElementById("banAmount").addEventListener("input", updateBanUI);
document.getElementById("banUnit").addEventListener("change", updateBanUI);
function sendannoucement() {
const announcement = document.getElementById('announcementText').value;
let announcementId = document.getElementById('announcementid').value;
if (announcement.trim() === "") {
showPopup("Error", "Please enter an announcement.");
return;
}
if (!announcementId.trim()) {
announcementId = "1";
}
const formData = new FormData();
formData.append("message", announcement);
formData.append("playerId", announcementId);
fetch("/admin/api/send_annoucement", {
method: "POST",
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showPopup("Success!", data.message);
} else {
showPopup("Failed", data.message);
}
})
.catch(error => {
showPopup("Error", "An error occurred: " + error.message);
});
}
function giveTokens(playerid) {
const tokenAmount = document.getElementById('token-amount').value;
let playerId = playerid;
if (!tokenAmount || tokenAmount <= 0) {
showPopup("Error", "Please enter a valid amount of tokens.");
return;
}
if (!playerId.trim()) {
showPopup("Error", "Please provide a player ID.");
return;
}
const formData = new FormData();
formData.append("tokenAmount", tokenAmount);
formData.append("playerId", playerId);
fetch("/admin/api/give_tokens", {
method: "POST",
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showPopup("Success!", `Successfully gave ${tokenAmount} tokens to player ${playerId}.`);
} else {
showPopup("Failed", data.message);
}
})
.catch(error => {
showPopup("Error", "An error occurred: " + error.message);
});
}
function kick_player(playerId) {
const formData = new FormData();
formData.append("playerId", playerId);
fetch("/admin/api/kick_player", {
method: "POST",
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showPopup("Success!", data.message,);
closePopup("player-popup")
} else {
showPopup("Failed", data.message,);
closePopup("player-popup")
}
})
.catch(error => {
showPopup("Error", "An error occurred: " + error.message);
});
}
function setup_html_show(data_tab) {
let tabContent = document.getElementById(data_tab);
if (!tabContent) {
tabContent = document.getElementById("home");
if (!tabContent) return; // still fail-safe if "home" somehow doesnt exist lol
}
document.querySelectorAll('.content').forEach(content => {
content.classList.remove('active');
content.style.display = 'none';
content.style.opacity = 0;
});
tabContent.classList.add('active');
tabContent.style.display = 'block';
setTimeout(() => {
tabContent.style.opacity = 1;
}, 10);
if (data_tab === 'Server' && typeof socialAppsLoad === 'function') socialAppsLoad();
}
// ---- Steam allowlist -------------------------------------------------
// STEAM ONLY. Quest/Meta logins never consult this list. An empty list turns
// the check off entirely, which the state line calls out explicitly - an
// allowlist that silently stopped filtering would be worth noticing.
// Steam cell in the player tables: the id plus a one-click allowlist button.
//
// Uses player.SteamID, NOT player.PlatformID. PlatformID is the first non-zero
// id of any platform, so on a Quest account it holds an Oculus id even though the
// column is labelled "Steam" - adding that to a Steam allowlist would be junk.
// No Steam id means no button.
function steamCellHtml(player) {
const sid = (player.SteamID || '').trim();
if (!sid) {
const other = (player.PlatformID || '').trim();
return other
? '<span style="opacity:.65">' + other + ' <em>(not Steam)</em></span>'
: '<span style="opacity:.5">none</span>';
}
return 'Steam ' + sid +
'<br><button class="btn btn-info" style="margin-top:4px; padding:3px 8px; font-size:12px"' +
' data-steamid="' + sid + '" onclick="steamAllowAddFrom(this)">Add to whitelist</button>';
}
// Discord cell in the player tables: the linked Discord username above its user id.
// DiscordId/DiscordUsername come from GetAccountBulk_full; both empty = not linked.
function discordCellHtml(player) {
const did = (player.DiscordId || '').toString().trim();
const dname = (player.DiscordUsername || '').trim();
if (!did && !dname) return '<span style="opacity:.5">not linked</span>';
return (dname ? escapeHtml(dname) : '<em>unknown</em>') +
(did ? '<br><span style="opacity:.7; font-size:12px">' + escapeHtml(did) + '</span>' : '');
}
// Adds straight from a player row and reports on the button itself, so the admin
// gets feedback without a popup interrupting a scan down the table.
function steamAllowAddFrom(btn) {
const sid = btn.getAttribute('data-steamid');
if (!sid) return;
const original = btn.textContent;
btn.disabled = true;
btn.textContent = 'Adding...';
const fd = new FormData();
fd.append('steamId', sid);
fetch('/admin/api/steam_allowlist/add', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
btn.textContent = data.success ? 'Whitelisted' : (data.message || 'Failed');
if (data.success) { btn.classList.add('role-on'); }
else { btn.disabled = false; setTimeout(() => btn.textContent = original, 2500); }
// Keep the Settings list in step if it is already on screen.
if (document.getElementById('steamAllowList')) steamAllowLoad();
})
.catch(err => {
btn.disabled = false;
btn.textContent = original;
showPopup('Error!', String(err));
});
}
function steamAllowLoad() {
fetch('/admin/api/steam_allowlist', { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
const state = document.getElementById('steamAllowState');
const list = document.getElementById('steamAllowList');
if (!state || !list) return;
if (data.success === false) {
state.textContent = data.message || 'Could not load the allowlist.';
return;
}
state.innerHTML = data.active
? '<span style="color:#7CFC9B">ACTIVE</span> — ' + data.count +
' Steam ID(s) may connect. Every other Steam user is refused at login.'
: '<span style="color:#FFC46B">OFF</span> — the list is empty, so all Steam users can connect.';
if (!data.entries || !data.entries.length) {
list.innerHTML = '<p style="opacity:.6">No IDs on the list.</p>';
return;
}
list.innerHTML = data.entries.map(e => {
const who = e.username
? '@' + e.username + ' (id ' + e.accountId + ')'
: '<span style="opacity:.55">no linked Campus account</span>';
return '<div style="display:flex; align-items:center; gap:10px; padding:8px 4px;' +
' border-bottom:1px solid rgba(255,255,255,.08)">' +
'<code style="flex:0 0 auto">' + e.steamId + '</code>' +
'<span style="flex:1 1 auto">' + who + '</span>' +
'<button class="btn btn-danger" onclick="steamAllowRemove(&quot;' + e.steamId + '&quot;)">Remove</button>' +
'</div>';
}).join('');
})
.catch(err => {
const state = document.getElementById('steamAllowState');
if (state) state.textContent = 'Could not load the allowlist: ' + err;
});
}
function steamAllowAdd() {
const input = document.getElementById('steamAllowInput');
const id = (input.value || '').trim();
if (!id) { showPopup('Error!', 'Enter a SteamID64.'); return; }
const fd = new FormData();
fd.append('steamId', id);
fetch('/admin/api/steam_allowlist/add', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Success!' : 'Error!', data.message || '');
if (data.success) input.value = '';
steamAllowLoad();
})
.catch(err => showPopup('Error!', String(err)));
}
function steamAllowRemove(steamId) {
if (!confirm('Remove SteamID ' + steamId + ' from the allowlist?')) return;
const fd = new FormData();
fd.append('steamId', steamId);
fetch('/admin/api/steam_allowlist/remove', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
showPopup(data.success ? 'Success!' : 'Error!', data.message || '');
steamAllowLoad();
})
.catch(err => showPopup('Error!', String(err)));
}
function setup_html() {
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', function () {
document.querySelectorAll('.tab').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.content').forEach(content => {
content.classList.remove('active');
content.style.display = 'none';
content.style.opacity = 0;
});
const tabContent = document.getElementById(tab.getAttribute('data-tab'));
if (tab.getAttribute('data-tab') === 'Settings'
&& typeof steamAllowLoad === 'function') steamAllowLoad();
if (tab.getAttribute('data-tab') === 'Database'
&& typeof loadBackups === 'function') loadBackups();
if (tab.getAttribute('data-tab') === 'Reports'
&& typeof reportsLoad === 'function') reportsLoad();
if (tab.getAttribute('data-tab') === 'Reports' && typeof bugsLoad === 'function') bugsLoad();
if (tab.getAttribute('data-tab') === 'Economy' && typeof earningsLoad === 'function') earningsLoad();
if (tab.getAttribute('data-tab') === 'Staff'
&& typeof staffLoad === 'function') staffLoad();
if (tab.getAttribute('data-tab') === 'Featured') {
if (typeof loadFeaturedRooms === 'function') loadFeaturedRooms();
if (typeof loadFeaturedPlayers === 'function') loadFeaturedPlayers();
}
tab.classList.add('active');
tabContent.classList.add('active');
tabContent.style.display = 'block';
setTimeout(() => {
tabContent.style.opacity = 1;
}, 10);
});
});
setup_html_show("home");
// Show the open-report count on the tab straight away, then keep it current.
if (typeof reportsPoll === 'function') { reportsPoll(); setInterval(reportsPoll, 60000); }
}
// ---- Players table: room-status cell + force join ----
function escapeHtml(s) {
return (s == null ? '' : String(s)).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// For a value dropped into a SINGLE-quoted JS string inside a DOUBLE-quoted HTML attribute
// (the onclick="fn('...')" handlers): JS-escape backslash + quote, then HTML-escape so a
// stray " can't close the attribute. Without this, a player's bio/name could break out and
// run script in the admin's browser (stored XSS with the admin's session).
function attrJs(s) {
return escapeHtml(String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"));
}
// Where a player is, for the Players table. Name (+ [PRIVATE]) with instance/room ids, or online/offline.
function roomCellHtml(player) {
if (player.RoomName) {
const priv = player.RoomIsPrivate ? '<span class="rt-priv">[PRIVATE]</span> ' : '';
return '<span class="rt-dot rt-on"></span>' + priv + '<b>' + escapeHtml(player.RoomName) + '</b>'
+ '<br><span class="subtle">inst ' + player.RoomInstanceId + ' · room ' + player.RoomId + '</span>';
}
if (player.IsOnline) return '<span class="rt-dot rt-on"></span><span class="subtle">online · in menu</span>';
return '<span class="rt-dot rt-off"></span><span class="subtle">offline</span>';
}
async function forceJoin() {
const joiner = (document.getElementById('fj-joiner').value || '').trim();
const target = (document.getElementById('fj-target').value || '').trim();
const status = document.getElementById('fj-status');
if (!joiner || !target) { status.textContent = 'Enter both player ids.'; return; }
status.textContent = 'Joining…';
try {
const body = new URLSearchParams({ joinerId: joiner, targetId: target });
const res = await fetch('/admin/api/force_join', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body
});
const data = await res.json();
if (data.success) status.textContent = '✓ Sent ' + joiner + " into " + target + "'s room (" + (data.roomName || '?') + ', inst ' + data.roomInstanceId + ')';
else status.textContent = '✗ ' + (data.error || 'failed');
} catch (e) { status.textContent = '✗ ' + e.message; }
}
// ---- Server Log tab: tails /admin/api/apilog into the console, auto-refreshing while open ----
let _logTimer = null;
function _logEsc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function _logRender(text) {
const el = document.getElementById('log-console');
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
const rows = (text || '').split('\n').map(line => {
const cls = /(^|\D)(4\d\d|5\d\d)(\D|$)/.test(line) ? 'l-err'
: /(^|\D)2\d\d(\D|$)/.test(line) ? 'l-ok' : 'l-dim';
return '<span class="' + cls + '">' + _logEsc(line) + '</span>';
});
el.innerHTML = rows.join('\n') || '(log is empty)';
if (atBottom) el.scrollTop = el.scrollHeight; // stick to newest unless the user scrolled up
}
async function refreshLog() {
const status = document.getElementById('log-status');
const lines = (document.getElementById('log-lines') || {}).value || 500;
const find = ((document.getElementById('log-filter') || {}).value || '').trim();
let url = '/admin/api/apilog?lines=' + encodeURIComponent(lines);
if (find) url += '&find=' + encodeURIComponent(find);
try {
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
_logRender(await res.text());
if (status) status.textContent = 'updated ' + new Date().toLocaleTimeString();
} catch (e) {
if (status) status.textContent = 'error: ' + e.message;
}
}
function clearLogView() {
const el = document.getElementById('log-console');
if (el) el.innerHTML = '';
}
function _logTick() {
const logDiv = document.getElementById('Log');
const auto = document.getElementById('log-auto');
if (logDiv && logDiv.classList.contains('active') && auto && auto.checked) refreshLog();
}
document.addEventListener('DOMContentLoaded', function () {
if (!_logTimer) _logTimer = setInterval(_logTick, 3000);
const logTab = document.querySelector('.tab[data-tab="Log"]');
if (logTab) logTab.addEventListener('click', () => setTimeout(refreshLog, 50));
// Players list + online/room statuses refresh live (every 10s) while the Players tab is open.
if (!_playersTimer) _playersTimer = setInterval(_playersTick, 10000);
const playersTab = document.querySelector('.tab[data-tab="Players"]');
if (playersTab) playersTab.addEventListener('click', () => setTimeout(_playersTick, 50));
const filter = document.getElementById('log-filter');
if (filter) filter.addEventListener('keydown', e => { if (e.key === 'Enter') refreshLog(); });
const linesSel = document.getElementById('log-lines');
if (linesSel) linesSel.addEventListener('change', refreshLog);
});
async function fetchAsync(url) {
// Send the auth cookie explicitly, and turn a silent failure into a real error.
// Before, any non-200 (a 401 with an empty body, or a 500 HTML page) made
// response.json() throw with a useless "Unexpected end of JSON" and every stat
// sat on "Loading..." forever. Now the reason surfaces.
let response;
// Hard timeout so a request that never answers can't leave a card on "Loading..."
// forever - it becomes a real, surfaced error after 15s instead.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
response = await fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json' }, signal: ctrl.signal });
} catch (e) {
throw new Error((e && e.name === 'AbortError' ? 'timed out (15s) contacting ' : 'network error contacting ') + url);
} finally {
clearTimeout(timer);
}
if (response.status === 401) {
throw new Error('not authorised (401) for ' + url + ' - session expired? Re-log in.');
}
if (!response.ok) {
throw new Error('HTTP ' + response.status + ' from ' + url);
}
const text = await response.text();
if (!text) throw new Error('empty response from ' + url);
try {
return JSON.parse(text);
} catch (e) {
throw new Error('bad JSON from ' + url + ': ' + text.slice(0, 120));
}
}
document.addEventListener("DOMContentLoaded", function () {
document.getElementById("onlinePlayersPopup").style.display = "none";
});
function updateStats() {
const startTime = performance.now();
fetchAsync("/admin/api/dashboard_data").then(function (data) {
const endTime = performance.now();
const duration = Math.round(endTime - startTime);
document.getElementById("ms_ping").innerText = duration + " ms";
//ram_mem
document.getElementById("ram_mem").innerText = data.mem_usage;
document.getElementById("motd_msg").innerText = data.MOTD;
document.getElementById("onlinePlayers").innerText = data.online_players;
document.getElementById("totalRooms").innerText = data.total_rooms;
document.getElementById("totalPlayers").innerText = data.total_players;
document.getElementById("inventions").innerText = data.total_inventions;
document.getElementById("clubs").innerText = data.total_clubs;
var playerList = data.quick_link.map(player => `
<div class="card">
<h3>${player.Title}</h3>
<p class="card-text">${player.disc}</p>
<a onclick="GoToPage('${player.Button_url}')" class="btn btn-info">${player.Button_Title}</a>
</div>
`).join('');
document.getElementById("dashboard_quicklink").innerHTML = playerList;
const header = document.querySelector("#Players h2");
if (header) {
header.innerText = `Players (count: ${data.total_players})`;
}
}).catch(function (err) {
// Don't leave every stat on "Loading..." forever - show why it failed.
["motd_msg","ram_mem","ms_ping","onlinePlayers","totalRooms","totalPlayers","inventions","clubs"]
.forEach(id => { const el = document.getElementById(id); if (el) el.innerText = "error"; });
console.error("dashboard load failed:", err);
showPopup("Dashboard failed to load", String(err && err.message || err));
});
}
function communityboardMsg() {
fetchAsync("/api/communityboard/v2/current").then(function (data) {
const messageContainer = document.querySelector("#communityboard_Message");
let Room_List = '';
if (data.FeaturedRoomGroup?.Rooms?.length) {
data.FeaturedRoomGroup.Rooms.forEach(function (room) {
Room_List += `
<tr>
<td>${room.RoomId}</td>
<td>
<img src="/img/${encodeURIComponent(room.ImageName)}" class="profile-img" alt="${escapeHtml(room.RoomName)}'s Room Image" style="width:50px;height:50px;">
</td>
<td>${escapeHtml(room.RoomName)}</td>
<td>
<a class="btn btn-warning btn-room-view" href="/admin/home/view_room/${room.RoomId}">View Room Details</a>
</td>
</tr>
`;
});
} else {
Room_List = `
<tr>
<td colspan="4">No featured rooms available.</td>
</tr>
`;
}
document.getElementById("communityboard_rooms").innerHTML = Room_List;
if (messageContainer && data.CurrentAnnouncement) {
const hasUrl = data.CurrentAnnouncement.MoreInfoUrl && data.CurrentAnnouncement.MoreInfoUrl.trim() !== "";
messageContainer.innerHTML = `
${data.CurrentAnnouncement.Message.replace(/\n/g, "<br>")}
<br>
<a ${hasUrl ? `href="${data.CurrentAnnouncement.MoreInfoUrl}" target="_blank"` : ""}
style="text-decoration:none;">
<button
style="
background-color:${hasUrl ? '#007bff' : '#6c757d'};
color:white;
border:none;
padding:5px 12px;
border-radius:4px;
cursor:${hasUrl ? 'pointer' : 'not-allowed'};
"
${hasUrl ? "" : "disabled"}>
More Info
</button>
</a>
`;
}
const playerId = data.FeaturedPlayer?.Id;
if (playerId) {
fetchAsync(`/admin/api/account/bulk?id=${playerId}`).then(function (playerData) {
const player = playerData[0];
const playerContainer = document.getElementById("communityboard_player");
const imageUrl = player.profileImage ? `/img/${player.profileImage}` : '/img/DefaultImg.png';
playerContainer.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center; gap:10px;">
<!-- Title -->
<p style="margin:0;">${data.FeaturedPlayer.TitleOverride || "Featured Player"}</p>
<!-- PFP + Username/Bio -->
<div style="display:flex;align-items:center;gap:10px;">
<img id="profileimage"
src="${imageUrl}"
alt="Profile Image"
class="profile-img"
onclick="viewPlayer('${player.accountId}')"
style="width:80px;height:80px;border-radius:8px;object-fit:cover;cursor:pointer;">
<div style="text-align:left;">
<strong>@${player.username}</strong><br>
<span>Test bio</span>
</div>
</div>
<!-- Buttons below the row -->
<div style="display:flex; gap:10px;">
<button style="background-color:#007bff;color:white;border:none;padding:5px 12px;border-radius:4px;cursor:pointer;">
How to Build
</button>
<button style="background-color:#007bff;color:white;border:none;padding:5px 12px;border-radius:4px;cursor:pointer;">
View Rooms
</button>
</div>
</div>
`;
});
}
});
}
function escapehtml(unsafe) { // prevent xss
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
async function viewProfile() {
try {
const meData = await AccountMe();
const accountId = meData.accountId;
viewPlayer(accountId, true);
closePopup('alert-popup');
} catch (error) {
showPopup("Error!", "Unable to load your profile: " + error);
}
}
async function AccountMe() {
try {
const data = await fetchAsync("/Accounts/account/me");
if (data) {
const profileImageUrl = `/img/${data.profileImage}?width=40&height=40&cropSquare=true`;
document.getElementById("profile-image").src = profileImageUrl;
document.getElementById("you-button").innerHTML = `
<img id="profile-image" src="${profileImageUrl}" alt="@${escapeHtml(data.username)}'s Profile Image" class="profile-img2"
style="width: 30px; height: 30px; margin-right: 10px;">
YOU (${escapeHtml(data.username)})
`;
const header = document.querySelector("#dashboard-title");
if (header) {
header.innerText = `Welcome to the Dashboard, ${data.username}`;
}
return data;
}
} catch (error) {
showPopup("Failed to get your account! Try logging out and logging back in.", error);
throw error;
}
}
function formatDate(isoString) {
if (!isoString || isoString === "0001-01-01T00:00:00") return "None";
const date = new Date(isoString);
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
};
return date.toLocaleString('en-US', options);
}
// Live-refresh state: which loader is active (so auto-refresh preserves a search), and the timer.
var _playersSearchActive = false;
var _playersTimer = null;
function _renderPlayerRows(data) {
var playerList = '';
data.forEach(function (player) {
playerList += `
<tr>
<td>${player.AccountId}</td>
<td>
<img loading="lazy" src="/img/${player.ProfileImage}?width=256&cropSquare=true" class="profile-img"
alt="${escapeHtml(player.Username)}">
</td>
<td>${escapeHtml(player.Username)}</td>
<td>${steamCellHtml(player)}</td>
<td style="max-width: 200px; word-break: break-word;">no not yet</td>
<td>${player.Level}</td>
<td>${formatDate(player.CreatedAt)}</td>
<td>${formatDate(player.LastLoginTime)}</td>
<td>${roomCellHtml(player)}</td>
<td style="max-width: 200px; word-break: break-word;">${discordCellHtml(player)}</td>
<td>
<div class="cell-actions">
<a class="btn btn-warning btn-room-view" href="/admin/home/view_room/${player.dormroom_id}">View Dorm</a>
<button class="btn btn-warning btn-edit"
onclick="viewPlayer('${player.AccountId}', '${attrJs(player.Username)}', '${attrJs(player.Bio)}', '/img/${attrJs(player.ProfileImage)}')">View Player</button>
<button class="btn btn-role ${player.IsDev ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="dev" data-on="${player.IsDev ? 1 : 0}"
onclick="toggleRole(this)">Dev</button>
<button class="btn btn-role ${player.IsMod ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="mod" data-on="${player.IsMod ? 1 : 0}"
onclick="toggleRole(this)">Mod</button>
<button class="btn btn-role ${player.IsScreenshare ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="screenshare" data-on="${player.IsScreenshare ? 1 : 0}"
onclick="toggleRole(this)">Screenshare</button>
<button class="btn btn-role ${player.IsSupport ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="support" data-on="${player.IsSupport ? 1 : 0}"
onclick="toggleRole(this)">Support</button>
<button class="btn btn-role ${player.IsInfluencer ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="influencer" data-on="${player.IsInfluencer ? 1 : 0}"
onclick="toggleRole(this)">Influencer</button>
<button class="btn btn-danger btn-delete-player"
onclick="deletePlayer('${player.AccountId}', '${attrJs(player.Username)}')">Delete</button>
</div>
</td>
</tr>
`;
});
document.getElementById("player-list").innerHTML = playerList;
}
function loadPlayers() {
_playersSearchActive = false; // full list is showing; auto-refresh reloads all
fetchAsync("/admin/api/players").then(_renderPlayerRows);
}
// Re-run the active loader (search or full) so the list + online/room statuses stay live.
// Only while the Players tab is visible, so we don't poll in the background.
function _playersTick() {
var pane = document.getElementById('Players');
if (!pane || !pane.classList.contains('active')) return;
if (_playersSearchActive) { if (typeof searchPlayers === 'function') searchPlayers(); }
else loadPlayers();
var s = document.getElementById('players-live');
if (s) s.textContent = 'live · updated ' + new Date().toLocaleTimeString();
}
async function goto_ban(name, accountid, profileimage, mbd) {
document.getElementById("banTitle").textContent = `Ban @${name}`;
document.getElementById("playerid").value = accountid;
const avatarImg = document.getElementById("accountimage1");
avatarImg.src = `/img/${profileimage}?width=128&cropSquare=true`;
avatarImg.style.display = "block";
const banStatusDiv = document.getElementById("banStatus");
if (mbd && mbd.IsBan === true) {
const friendlyDuration = formatDuration(mbd.Duration);
banStatusDiv.className = "ban-status bad";
banStatusDiv.innerHTML = `
<i class="fas fa-ban"></i>
<div>
<strong>@${name} is currently banned</strong><br>
${friendlyDuration}<br>
Reason: ${mbd.Message}
</div>
`;
} else {
banStatusDiv.className = "ban-status ok";
banStatusDiv.innerHTML = `
<i class="fas fa-check-circle"></i>
<strong>@${name} is not currently banned</strong>
`;
}
banStatusDiv.style.display = "flex";
closePopup("player-popup");
document.getElementById("ban-modal").classList.add("active");
}
function formatDuration(seconds) {
const years = Math.floor(seconds / (365 * 24 * 60 * 60));
seconds %= 365 * 24 * 60 * 60;
const days = Math.floor(seconds / (24 * 60 * 60));
seconds %= 24 * 60 * 60;
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
let parts = [];
if (years > 0) parts.push(`${years} year${years !== 1 ? "s" : ""}`);
if (days > 0) parts.push(`${days} day${days !== 1 ? "s" : ""}`);
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
if (parts.length === 0) return "less than a minute";
return parts.join(", ");
}
function searchPlayers() {
_playersSearchActive = true; // auto-refresh will re-run this search to keep statuses live
const params = new URLSearchParams();
const id = document.getElementById("searchPlayerId").value;
const displayName = document.getElementById("searchDisplayName").value;
const email = document.getElementById("searchEmail").value;
const minLevel = document.getElementById("searchMinLevel").value;
const jrState = document.getElementById("searchJrState").value;
const ip = document.getElementById("searchIp").value;
const deviceId = document.getElementById("searchDeviceId").value;
const platformId = document.getElementById("searchPlatformId").value;
const order = document.getElementById("searchOrder").value;
if (id) params.append("id", id);
if (displayName) params.append("displayName", displayName);
if (email) params.append("email", email);
if (minLevel) params.append("minLevel", minLevel);
if (jrState && jrState !== "N/A") params.append("jrState", jrState);
if (ip) params.append("ip", ip);
if (deviceId) params.append("deviceId", deviceId);
if (platformId) params.append("platformId", platformId);
if (order) params.append("order", order);
if (document.getElementById("searchDisplayHistory").checked)
params.append("searchDisplayHistory", "true");
if (document.getElementById("searchPartialMatches").checked)
params.append("partial", "true");
if (document.getElementById("searchJuniorsOnly").checked)
params.append("juniorsOnly", "true");
if (document.getElementById("searchBannedOnly").checked)
params.append("banned", "true");
if (document.getElementById("searchOnlineOnly").checked)
params.append("online", "true");
if (document.getElementById("searchIgnoreNoBirthday").checked)
params.append("ignoreNoBirthday", "true");
fetchAsync(`/admin/api/players?${params.toString()}`).then(function (data) {
let playerList = '';
data.forEach(function (player) {
playerList += `
<tr>
<td>${player.AccountId}</td>
<td>
<img src="/img/${player.ProfileImage}?width=256&cropSquare=true" class="profile-img"
alt="${escapeHtml(player.Username)}">
</td>
<td>${escapeHtml(player.Username)}</td>
<td>${steamCellHtml(player)}</td>
<td style="max-width: 200px; word-break: break-word;">no not yet</td>
<td>${player.Level}</td>
<td>${formatDate(player.CreatedAt)}</td>
<td>${formatDate(player.LastLoginTime)}</td>
<td>${roomCellHtml(player)}</td>
<td style="max-width: 200px; word-break: break-word;">${discordCellHtml(player)}</td>
<td>
<div class="cell-actions">
<a class="btn btn-warning btn-room-view" href="/admin/home/view_room/${player.dormroom_id}">View Dorm</a>
<button class="btn btn-warning btn-edit"
onclick="viewPlayer('${player.AccountId}', '${attrJs(player.Username)}', '${attrJs(player.Bio)}', '/img/${attrJs(player.ProfileImage)}')">View Player</button>
<button class="btn btn-role ${player.IsDev ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="dev" data-on="${player.IsDev ? 1 : 0}"
onclick="toggleRole(this)">Dev</button>
<button class="btn btn-role ${player.IsMod ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="mod" data-on="${player.IsMod ? 1 : 0}"
onclick="toggleRole(this)">Mod</button>
<button class="btn btn-role ${player.IsScreenshare ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="screenshare" data-on="${player.IsScreenshare ? 1 : 0}"
onclick="toggleRole(this)">Screenshare</button>
<button class="btn btn-role ${player.IsSupport ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="support" data-on="${player.IsSupport ? 1 : 0}"
onclick="toggleRole(this)">Support</button>
<button class="btn btn-role ${player.IsInfluencer ? 'role-on' : ''}"
data-id="${player.AccountId}" data-role="influencer" data-on="${player.IsInfluencer ? 1 : 0}"
onclick="toggleRole(this)">Influencer</button>
<button class="btn btn-danger btn-ban" data-id="${player.AccountId}">Ban</button>
<button class="btn btn-danger btn-delete-player"
onclick="deletePlayer('${player.AccountId}', '${attrJs(player.Username)}')">Delete</button>
</div>
</td>
</tr>`;
});
document.getElementById("player-list").innerHTML = playerList;
});
}
var _roomsQ = '', _roomsSkip = 0, _roomsTake = 200, _roomsTotal = 0;
// Jump pages: dir -1 = previous, +1 = next. Keeps the current search query.
function roomsPage(dir) {
var ns = _roomsSkip + dir * _roomsTake;
if (ns < 0) ns = 0;
if (ns >= _roomsTotal) return;
loadRooms(undefined, ns);
}
// q: new search (resets to page 1). skip: jump to an offset. Neither: reload current view.
function loadRooms(q, skip) {
if (q !== undefined && q !== null) { _roomsQ = q; _roomsSkip = 0; }
if (skip !== undefined && skip !== null) _roomsSkip = skip;
var url = "/admin/api/rooms?take=" + _roomsTake + "&skip=" + _roomsSkip + (_roomsQ ? "&q=" + encodeURIComponent(_roomsQ) : "");
fetchAsync(url).then(function (data) {
// Endpoint returns { total, skip, take, rooms:[...] } (paged/projected). Tolerate the
// old bare-array shape too, and never let one null field blank the whole table.
var rooms = Array.isArray(data) ? data : (data && data.rooms) || [];
_roomsTotal = Array.isArray(data) ? data.length : (data && data.total) || rooms.length;
var roomList = '';
rooms.forEach(function (room) {
var name = (room.Name || '').trim();
var desc = room.Description || '';
roomList += `
<tr>
<td>${room.RoomId}</td>
<td>
<img width=100 loading="lazy" src="/img/${room.ImageName || 'DefaultRoomImage.jpg'}?width=256&cropSquare=true" class="room-img" alt="Room Image">
</td>
<td>^${escapeHtml(name)}</td>
<td>${escapeHtml(desc)}</td>
<td>${room.CreatorAccountId}</td>
<td>${room.MaxPlayers}</td>
<td>
<a href="/admin/edit-room/${room.RoomId}" class="btn btn-warning btn-edit">Edit</a>
<button class="btn btn-danger btn-delete" data-id="${room.RoomId}">Delete</button>
</td>
</tr>
`;
});
document.getElementById("rooms_list").innerHTML = roomList;
// Pager state
var pageCount = Math.max(1, Math.ceil(_roomsTotal / _roomsTake));
var curPage = Math.floor(_roomsSkip / _roomsTake) + 1;
var start = _roomsTotal === 0 ? 0 : _roomsSkip + 1;
var end = _roomsSkip + rooms.length;
var cnt = document.getElementById("rooms_count");
if (cnt) cnt.textContent = "showing " + start + "–" + end + " of " + _roomsTotal
+ (_roomsQ ? " matching “" + _roomsQ + "”" : "") + " · page " + curPage + " / " + pageCount;
var prev = document.getElementById("rooms_prev"), next = document.getElementById("rooms_next");
if (prev) prev.disabled = _roomsSkip <= 0;
if (next) next.disabled = end >= _roomsTotal;
// scroll table into view on page change
window.scrollTo({ top: document.getElementById("rooms_list").getBoundingClientRect().top + window.scrollY - 120, behavior: "smooth" });
});
}
function loadInventions() {
fetchAsync("/admin/api/inventions").then(function (data) {
var inventionList = '';
data.forEach(function (invention) {
inventionList += `
<tr>
<td>${invention.InventionId}</td>
<td>
<img src="/img/${invention.ImageName}?width=256&cropSquare=true" class="profile-img" alt="inventionimg">
</td>
<td>${invention.Name}</td>
<td>${formatDate(invention.CreatedAt)}</td>
<td>${formatDate(invention.LastModifiedAt)}</td>
<td>${formatDate(invention.CreatedAt)}</td>
<td>
<button class="btn btn-warning btn-edit" onclick="viewInvention('${invention.InventionId}')">View
Invention</button>
</td>
</tr>
`;
});
document.getElementById("inventions_list").innerHTML = inventionList;
});
}
// ---- Room import drag & drop (uploads become @Importer rooms) ----
(function initImportDrop() {
const drop = document.getElementById('importDrop');
const input = document.getElementById('importFileInput');
if (!drop || !input) return;
const status = document.getElementById('importStatus');
const result = document.getElementById('importResult');
const lo = () => { drop.style.borderColor = 'var(--line)'; drop.style.background = 'transparent'; };
const hi = () => { drop.style.borderColor = 'var(--primary)'; drop.style.background = 'var(--primary-soft)'; };
drop.addEventListener('click', () => input.click());
drop.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); hi(); }));
['dragleave', 'dragend'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); lo(); }));
drop.addEventListener('drop', e => { e.preventDefault(); lo(); if (e.dataTransfer && e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
input.addEventListener('change', () => { if (input.files.length) uploadFiles(input.files); input.value = ''; });
function uploadFiles(files) {
// XHR (not fetch) so we can show real upload progress and never time out — a room
// zip can be large, and fetch gives no progress so it looked like it "never finished".
const fd = new FormData();
for (const f of files) fd.append('files', f, f.name);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/admin/api/import_upload', true);
xhr.withCredentials = true;
xhr.timeout = 0;
if (result) result.textContent = '';
if (status) status.textContent = 'Uploading ' + files.length + ' file(s)…';
xhr.upload.onprogress = e => {
if (!status) return;
if (e.lengthComputable) status.textContent = 'Uploading… ' + Math.round(e.loaded / e.total * 100) + '% (' + fmtBytes(e.loaded) + ' / ' + fmtBytes(e.total) + ')';
else status.textContent = 'Uploading… ' + fmtBytes(e.loaded);
};
xhr.upload.onload = () => { if (status) status.textContent = 'Upload complete — unpacking & importing on the server…'; };
xhr.onload = () => {
let d = null; try { d = JSON.parse(xhr.responseText); } catch (e) { }
if (!d) { if (status) status.textContent = '✗ Server returned ' + xhr.status + ' (not JSON).'; return; }
if (status) status.textContent = (d.success ? '✓ ' : '✗ ') + (d.message || (d.success ? 'Done.' : 'Import failed.'));
const lines = [];
if (d.jsonFiles && d.jsonFiles.length) lines.push('Imported: ' + d.jsonFiles.join(', '));
if (d.blobFiles && d.blobFiles.length) lines.push('Blobs saved: ' + d.blobFiles.length);
if (d.blobsMissing && d.blobsMissing.length) lines.push('Still missing (' + d.blobsMissing.length + '): ' + d.blobsMissing.join(', '));
if (d.errors && d.errors.length) lines.push('Errors: ' + d.errors.join('; '));
if (result) result.textContent = lines.join('\n');
};
xhr.onerror = () => { if (status) status.textContent = '✗ Upload failed (network/size).'; };
xhr.ontimeout = () => { if (status) status.textContent = '✗ Upload timed out.'; };
xhr.send(fd);
}
})();
// ---- Database backups + server restart ----
function fmtBytes(n) {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB']; let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(i ? 1 : 0) + ' ' + u[i];
}
function loadBackups() {
const tb = document.getElementById('backup-list'); if (!tb) return;
tb.innerHTML = '<tr><td colspan="5" style="color:var(--muted);">Loading…</td></tr>';
fetch('/admin/api/backups', { credentials: 'same-origin' }).then(r => r.json()).then(d => {
const list = (d && d.backups) || [];
if (!list.length) { tb.innerHTML = '<tr><td colspan="5" style="color:var(--muted);">No backups yet — one is taken automatically every 8 hours, or press “Back up now”.</td></tr>'; return; }
tb.innerHTML = list.map(b => {
let when = b.createdAt; try { when = new Date(b.createdAt).toLocaleString(); } catch (e) { }
return '<tr><td><code>' + escapeHtml(b.name) + '</code></td><td>' + escapeHtml(when) + '</td><td>' + b.fileCount + '</td><td>' + fmtBytes(b.sizeBytes) + '</td>'
+ '<td><button class="btn btn-info" onclick="restoreBackup(\'' + escapeHtml(b.name) + '\')"><i class="fa-solid fa-rotate-left"></i> Recover</button></td></tr>';
}).join('');
}).catch(() => { tb.innerHTML = '<tr><td colspan="5" style="color:var(--danger);">Failed to load backups.</td></tr>'; });
}
function backupNow() {
const s = document.getElementById('db-status'); if (s) s.textContent = 'Backing up…';
fetch('/admin/api/backup_now', { method: 'POST', credentials: 'same-origin' }).then(r => r.json()).then(d => {
if (s) s.textContent = (d && d.success) ? ('✓ Backed up: ' + d.name) : 'Backup failed.';
loadBackups();
}).catch(() => { if (s) s.textContent = 'Backup failed.'; });
}
function restoreBackup(name) {
const msg = 'This replaces the CURRENT databases with “' + name + '” and restarts the server. Any changes since that snapshot are lost. Continue?';
const go = () => doRestore(name);
if (typeof openConfirm === 'function') openConfirm({ title: 'Recover this backup?', message: msg, confirmText: 'Recover & Restart', onConfirm: go });
else if (confirm(msg)) go();
}
function doRestore(name) {
const form = new URLSearchParams(); form.append('name', name);
fetch('/admin/api/restore_backup', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString() })
.then(r => r.json()).then(d => {
if (typeof showPopup === 'function') showPopup((d && d.success) ? 'Recovering' : 'Error', (d && d.message) || 'Restore failed.');
else alert((d && d.message) || 'Restore failed.');
}).catch(() => { alert('Restore request failed.'); });
}
function restartServer() {
const go = () => {
const s = document.getElementById('server-restart-status'); if (s) s.textContent = 'Restarting…';
fetch('/admin/api/restart', { method: 'POST', credentials: 'same-origin' }).then(r => r.json())
.then(d => { if (s) s.textContent = (d && d.message) || 'Restarting…'; })
.catch(() => { if (s) s.textContent = 'Restart requested.'; });
};
const msg = 'Players will be briefly disconnected. The server comes back in a few seconds.';
if (typeof openConfirm === 'function') openConfirm({ title: 'Restart server?', message: msg, confirmText: 'Restart', onConfirm: go });
else if (confirm(msg)) go();
}
function toggleRole(btn) {
const id = btn.dataset.id;
const role = btn.dataset.role;
const enable = btn.dataset.on !== "1";
const form = new URLSearchParams();
form.append("playerId", id);
form.append("role", role);
form.append("enabled", enable ? "1" : "0");
fetch("/admin/api/set_role", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.dataset.on = enable ? "1" : "0";
btn.classList.toggle("role-on", enable);
} else {
showPopup("Error!", data.message || "Role change failed.");
}
})
.catch(err => showPopup("Error!", "Role change failed: " + err));
}
function deletePlayer(accountId, username) {
if (!confirm(`Permanently delete player @${username} (id ${accountId})?\n\nThis wipes their account, rooms, settings, heartbeat, and image metadata. This cannot be undone.`)) {
return;
}
const form = new URLSearchParams();
form.append("playerId", accountId);
fetch("/admin/api/delete_player", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
showPopup("Success!", `Deleted @${username}.`);
if (typeof loadPlayers === "function") loadPlayers();
if (typeof loadPlayersFiltered === "function") loadPlayersFiltered();
} else {
showPopup("Error!", data.message || "Delete failed.");
}
})
.catch(err => showPopup("Error!", "Delete request failed: " + err));
}
async function viewPlayer(accountId, me) {
try {
showLoading();
const bulkData = await fetchAsync(`/admin/api/account/bulk?id=${accountId}`);
if (!Array.isArray(bulkData) || bulkData.length === 0) {
return;
}
const player = bulkData[0];
const { username, profileImage, displayName, bio, mbd, PlatformIDs } = player;
let bioText = "This player has not written a bio yet!";
if (bio && typeof bio === "string" && bio.trim().length > 0) {
bioText = bio;
}
const popup = document.getElementById('player-popup');
document.getElementById('player-popup-title').textContent = "@" + username;
document.getElementById('player-popup-text').textContent = "Account Id: " + accountId;
document.getElementById('player-popup-bio').textContent = bioText;
const platformsDiv = document.getElementById("player-popup-platforms");
if (platformsDiv) {
platformsDiv.innerHTML = `
<h4 class="popup-section-title">Linked Accounts</h4>
${renderPlatforms(PlatformIDs)}
`;
}
try {
const banStatusDiv = document.getElementById("player-popup-banstatus");
if (mbd && mbd.IsBan === true) {
const friendlyDuration = formatDuration(mbd.Duration);
banStatusDiv.className = "ban-status bad";
banStatusDiv.innerHTML = `
<i class="fas fa-ban"></i>
<div>
<strong>@${username} is currently banned</strong><br>
${friendlyDuration}<br>
Reason: ${mbd.Message}
</div>
`;
} else {
banStatusDiv.className = "ban-status ok";
banStatusDiv.innerHTML = `
<i class="fas fa-check-circle"></i>
<strong>@${name} is not currently banned</strong>
`;
}
banStatusDiv.style.display = "flex";
}
catch (e) {
console.error("Failed to fetch ban status:", e);
}
const icon = document.getElementById('player-popup-icon');
if (profileImage) {
icon.src = `/img/${profileImage}?width=256&cropSquare=1`;
icon.style.display = 'block';
} else {
icon.style.display = 'none';
}
const buttonContainer = document.getElementById('popup-buttons');
if (me === true) {
document.getElementById('player-popup-text').textContent =
"This is you!\nAccount Id: " + accountId;
buttonContainer.innerHTML = `
<button class="btn btn-danger" onclick="closePopup('player-popup')">Close</button>
<button class="btn btn-danger" onclick="GoToCampusNet()">Edit Your Profile</button>
`;
} else {
// IMPORTANT FIX:
// JSON.stringify + escaping quotes so HTML doesn't break
const mbdJson = JSON.stringify(mbd).replace(/"/g, '&quot;');
buttonContainer.innerHTML = `
<button class="btn btn-primary" onclick="sendMessage('${accountId}')">Send Message</button>
<button class="btn btn-danger" onclick="kick_player('${accountId}')">Kick @${username} from their current room</button>
<button class="btn btn-danger btn-ban" onclick="goto_ban('${username}', '${accountId}', '${profileImage}', ${mbdJson});">
Ban @${username}
</button>
<button class="btn btn-primary" onclick="unban_player('${accountId}', '${username}')">
Unban @${username}
</button>
<button class="btn btn-secondary" onclick="closePopup('player-popup')">Close</button>
`;
}
closePopup("alert-popup");
popup.classList.add('active');
} catch (error) {
console.error("Error loading player data:", error);
showPopup("Error loading player info.");
}
}
function renderPlatforms(platforms) {
if (!Array.isArray(platforms)) {
return `<span class="platform-badge muted">No linked platforms</span>`;
}
const valid = platforms.filter(p =>
p &&
p.Platform === 0 && // Steam only
typeof p.PlatformId === "string" &&
p.PlatformId !== "0"
);
if (valid.length === 0) {
return `<span class="platform-badge muted">No linked platforms</span>`;
}
return valid.map(p => `
<a class="platform-badge steam"
href="https://steamcommunity.com/profiles/${p.PlatformId}"
target="_blank">
<i class="fab fa-steam"></i>
${p.PlatformId}
</a>
`).join("");
}
function GoToCampusNet() {
closePopup('player-popup')
// Campus Net was removed; profiles are edited on the website now.
window.open('/settings', '_blank')
}
function sendMessage(accountId) {
closePopup('player-popup')
showPopup('Warning.', 'This is not implemented yet!')
}
// <button class="btn btn-danger" onclick="gotohiddenpageEditPlayer()">Edit Player</button>
// <button class="btn btn-primary" onclick="sendMessage('${accountId}')">Send Message</button>
async function viewInvention(inventionId) {
try {
const invention = await fetchAsync(`/admin/api/inventions_bulk/${inventionId}`);
if (!invention || !invention.CreatorPlayerId) {
alert("Invalid invention data.");
return;
}
const {
Name, Description, ImageName,
CreatorPlayerId, CreatedAt,
NumDownloads, CheerCount
} = invention;
const playerArray = await fetchAsync(`/admin/api/account/bulk?id=${CreatorPlayerId}`);
const playerData = Array.isArray(playerArray) && playerArray.length > 0 ? playerArray[0] : null;
const { username, profileImage, displayName } = playerData || {};
let bioText = "This player has not written a bio yet!";
try {
const bioData = await fetchAsync(`/Accounts/account/${CreatorPlayerId}/bio`);
if (bioData && typeof bioData.Bio === "string") {
bioText = bioData.Bio;
}
} catch (bioError) {
}
const popup = document.getElementById('player-popup');
document.getElementById('player-popup-title').textContent = Name || "Untitled Invention";
document.getElementById('player-popup-text').textContent =
`By: ${displayName || username || "Unknown"}<br>` +
`Account ID: ${CreatorPlayerId}<br>` +
`Created: ${new Date(CreatedAt).toLocaleString()}<br>` +
`Downloads: ${NumDownloads} | Cheers: ${CheerCount}`;
document.getElementById('player-popup-bio').textContent = Description || bioText;
const icon = document.getElementById('player-popup-icon');
if (ImageName) {
icon.src = `/img/${ImageName}?cropSquare=1`;
icon.style.display = 'block';
} else {
icon.style.display = 'none';
}
const buttonContainer = document.getElementById('popup-buttons');
buttonContainer.innerHTML = `
<button class="btn btn-secondary" onclick="closePopup('player-popup')">Close</button>
`;
popup.classList.add('active');
} catch (error) {
console.error(error);
}
}
function gotohiddenpageEditPlayer() {
closePopup("player-popup");
setup_html_show("EditPlayer");
}
document.getElementById('theme-selector').addEventListener('change', function () {
const selectedTheme = this.value;
document.body.classList.remove('campus', 'green', 'purple', 'dark', 'red', 'blue', 'pink', 'orange', 'yellow');
if (selectedTheme === 'campus_theme') {
document.body.classList.add('campus');
const homeImage = document.getElementById('home-image');
if (homeImage) homeImage.src = `/admin-assets/Cutout.png`;
} else if (selectedTheme === 'green_theme') {
document.body.classList.add('green');
const homeImage = document.getElementById('home-image');
homeImage.src = `/img/DefaultImgGreen.png`;
} else if (selectedTheme === 'purple_theme') {
document.body.classList.add('purple');
const homeImage = document.getElementById('home-image');
homeImage.src = `/img/DefaultImgPurple.png`;
}
else if (selectedTheme === 'pink_theme') {
document.body.classList.add('pink');
const homeImage = document.getElementById('home-image');
homeImage.src = `/img/DefaultImgPink.png`;
}
else if (selectedTheme === 'blue_theme') {
document.body.classList.add('blue');
const homeImage = document.getElementById('home-image');
homeImage.src = `/img/DefaultImg.png`;
}
else if (selectedTheme === 'orange_theme') {
document.body.classList.add('orange');
const homeImage = document.getElementById('home-image');
homeImage.src = `/img/DefaultImgOrange.png`;
}
else if (selectedTheme === 'yellow_theme') {
document.body.classList.add('yellow');
const homeImage = document.getElementById('home-image');
homeImage.src = `/img/DefaultImgYellow.png`;
} else if (selectedTheme === 'dark_theme') {
document.body.classList.add('dark');
const homeImage = document.getElementById('home-image');
homeImage.src = `/img/DefaultImgDark.png`;
} else {
// Fallback: Campus. Previously fell back to `red` from the E12354 base -
// now that we ship a Campus theme, default to it so an unknown selection
// still respects the brand.
document.body.classList.add('campus');
const homeImage = document.getElementById('home-image');
if (homeImage) homeImage.src = `/admin-assets/Cutout.png`;
}
localStorage.setItem('selectedTheme', selectedTheme);
});
document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('selectedTheme');
if (savedTheme) {
document.getElementById('theme-selector').value = savedTheme;
document.getElementById('theme-selector').dispatchEvent(new Event('change'));
}
});
function initDashboard() {
// Each step is isolated: a synchronous throw in one used to abort the array before
// the later ones were even called - which is exactly how updateStats() (and so
// every dashboard card) could be skipped while one earlier loader failed. Wrapping
// each call in Promise.resolve().then turns any throw into an isolated rejection,
// and setup_html runs in its own guard so a failure there can't skip the loaders.
try { setup_html(); } catch (e) { console.error('setup_html failed:', e); }
const step = (name, fn) =>
Promise.resolve().then(fn).catch(e => console.error(name + ' failed:', e));
step('AccountMe', AccountMe);
step('updateStats', updateStats);
step('loadPlayers', loadPlayers);
step('loadRooms', loadRooms);
step('communityboardMsg', communityboardMsg);
}
initDashboard();
</script>
</body>
</html>