7253 lines
484 KiB
HTML
7253 lines
484 KiB
HTML
<!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 rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||
<script>
|
||
/* Same theme as the website (Website/app.js keys), applied before first paint. */
|
||
(function () { try { var t = localStorage.getItem('campus-theme') || 'light';
|
||
if (['light', 'dark', 'autumn'].indexOf(t) < 0) t = 'light';
|
||
document.documentElement.setAttribute('data-theme', t); } catch (e) { } })();
|
||
</script>
|
||
<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 design for the admin panel: the main site's own tokens, themes
|
||
(light / dark / autumn + accent, shared with the site through localStorage
|
||
'campus-theme' / 'campus-accent'), top bar, side drawer, cards, buttons and
|
||
fields. Loaded LAST so it wins over the legacy panel CSS above; structure and
|
||
JS are untouched apart from the top bar / drawer markup below.
|
||
═══════════════════════════════════════════════════════════════════════════ -->
|
||
<style id="campus-website-skin">
|
||
/* ── website tokens (Website/style.css) ── */
|
||
:root{
|
||
--primary:#8B5CF6; --primary-dark:#6D28D9; --primary-soft:#EDE9FE;
|
||
--gradient-main:linear-gradient(135deg,#621A87 0%,#A41F73 20%,#CF2860 40%,#E73648 60%,#F7522D 80%,#FA651C 100%);
|
||
--bg:#ffffff; --panel:#f6f6f8; --line:#ececef; --text:#1c1c1e; --muted:#8a8a90;
|
||
--page:#f5f2ef; --card:#ffffff; --topbar-bg:rgba(255,255,255,0.82);
|
||
--topbar-h:64px; --side-w:236px; --radius:12px;
|
||
--ok:#15803d; --danger:#dc2626; --warn:#b45309;
|
||
--shadow:0 1px 2px rgba(0,0,0,.04);
|
||
/* legacy panel variables, pointed at the website palette */
|
||
--card-hover-bg:var(--primary); --table-bg:var(--card); --accent-color:var(--primary);
|
||
--popup-bg:var(--card); --navbar-bg:var(--topbar-bg); --card-bg:var(--card); --border:var(--line);
|
||
--btn-confirm-bg:var(--primary); --btn-cancel-bg:var(--panel); --btn-close-bg:var(--panel);
|
||
}
|
||
[data-theme="dark"]{
|
||
--bg:#1a1a2e; --panel:#2d2b4a; --line:rgba(255,255,255,0.09); --text:#f1f1f4; --muted:#a09fb0;
|
||
--page:#12101f; --card:#22203a; --topbar-bg:rgba(18,16,31,0.82); --primary-soft:rgba(139,92,246,0.16);
|
||
--ok:#4ade80; --danger:#f87171; --warn:#fbbf24;
|
||
}
|
||
[data-theme="autumn"]{
|
||
--primary:#d9641f; --primary-dark:#b54d12; --primary-soft:rgba(217,100,31,.13);
|
||
--gradient-main:linear-gradient(#d9641f,#d9641f);
|
||
--page:#f3e4cd; --card:#faf0df; --panel:#ecd8bb; --line:rgba(110,66,30,.14);
|
||
--text:#3a2416; --muted:#8a6a50; --topbar-bg:#3b2416;
|
||
}
|
||
|
||
/* ── base ── */
|
||
html, body, body.campus, body.red, body.green, body.purple, body.dark{
|
||
background:var(--page) !important; color:var(--text) !important;
|
||
font-family:'Roboto',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif !important;
|
||
font-size:14px; letter-spacing:-.01em; -webkit-font-smoothing:antialiased;
|
||
}
|
||
body{ transition:background .25s, color .25s; }
|
||
body.campus::before, #background-blur-layer, .space-background, #home-image{ display:none !important; }
|
||
::selection{ background:var(--primary); color:#fff; }
|
||
a{ color:var(--primary); }
|
||
a:hover{ color:var(--primary-dark); }
|
||
h1,h2,h3,h4,h5{ color:var(--text) !important; letter-spacing:-.022em; }
|
||
.muted, .subtle, small{ color:var(--muted) !important; }
|
||
|
||
/* ── top bar (the site's .topbar) ── */
|
||
.navbar{
|
||
position:fixed !important; inset:0 0 auto 0 !important; height:var(--topbar-h) !important; z-index:60 !important;
|
||
display:flex !important; align-items:center !important; justify-content:space-between !important; flex-wrap:nowrap !important;
|
||
padding:0 20px !important; gap:12px !important; margin:0 !important;
|
||
background:var(--topbar-bg) !important; backdrop-filter:saturate(180%) blur(16px); -webkit-backdrop-filter:saturate(180%) blur(16px);
|
||
border:none !important; border-bottom:1px solid var(--line) !important; box-shadow:none !important;
|
||
}
|
||
[data-theme="autumn"] .navbar{
|
||
--text:#f6e7cf; --muted:#d4b48f; --card:#4a2e1c; --panel:#4f3322; --line:rgba(255,255,255,.1);
|
||
--primary:#f2a35a; --primary-soft:rgba(255,255,255,.1);
|
||
box-shadow:0 1px 0 rgba(0,0,0,.35), 0 3px 0 #d9641f !important;
|
||
}
|
||
.top-left, .top-right{ display:flex; align-items:center; gap:8px; }
|
||
.top-right{ margin-left:auto; }
|
||
.side-toggle{ background:none !important; border:none !important; cursor:pointer; padding:0 !important;
|
||
width:44px; height:44px; display:flex; align-items:center; justify-content:center; border-radius:10px !important; }
|
||
.burger-cat{ width:36px; height:36px; object-fit:contain; display:block;
|
||
filter:invert(31%) sepia(62%) saturate(3600%) hue-rotate(252deg) brightness(93%) contrast(96%); transition:transform .3s; }
|
||
.side-toggle:hover .burger-cat{ transform:rotate(-8deg) scale(1.05); }
|
||
[data-theme="dark"] .burger-cat{ filter:invert(60%) sepia(50%) saturate(2400%) hue-rotate(232deg) brightness(105%) contrast(97%); }
|
||
[data-theme="autumn"] .burger-cat{ filter:invert(55%) sepia(80%) saturate(1800%) hue-rotate(345deg) brightness(100%) contrast(95%); }
|
||
.admin-brand{ display:flex; align-items:center; gap:8px; font-size:1.25rem; font-weight:700; color:var(--text);
|
||
letter-spacing:-.02em; white-space:nowrap; text-decoration:none !important; }
|
||
.admin-brand .admin-pill{ font-size:.68rem; font-weight:700; color:#fff; background:var(--gradient-main);
|
||
padding:3px 8px; border-radius:999px; letter-spacing:.02em; }
|
||
.icon-btn{ width:40px; height:40px; border-radius:10px !important; border:none !important; background:transparent !important;
|
||
color:var(--text) !important; display:inline-flex; align-items:center; justify-content:center; cursor:pointer;
|
||
transition:background .15s, color .15s; padding:0 !important; }
|
||
.icon-btn:hover{ background:var(--panel) !important; color:var(--primary) !important; }
|
||
.icon-btn svg{ width:21px; height:21px; }
|
||
.btn-login{ display:inline-flex; align-items:center; gap:6px; text-decoration:none !important; color:var(--text) !important;
|
||
font-weight:600; font-size:.88rem; padding:8px 14px; border-radius:8px; border:1.5px solid var(--line);
|
||
background:transparent; transition:border-color .15s, color .15s; white-space:nowrap; }
|
||
.btn-login:hover{ border-color:var(--primary); color:var(--primary) !important; }
|
||
#you-button, #you-button.btn, .navbar #you-button{
|
||
background:transparent !important; color:var(--text) !important; border:1.5px solid var(--line) !important;
|
||
border-radius:999px !important; font-weight:600 !important; font-size:.86rem !important;
|
||
padding:4px 12px 4px 4px !important; display:inline-flex !important; align-items:center; box-shadow:none !important;
|
||
transform:none !important;
|
||
}
|
||
#you-button:hover{ border-color:var(--primary) !important; color:var(--primary) !important; }
|
||
#you-button .profile-img2{ width:28px !important; height:28px !important; margin-right:8px !important; object-fit:cover; }
|
||
|
||
/* ── side drawer (the site's sidebar / .side-link) ── */
|
||
.admin-sidebar{
|
||
position:fixed; top:var(--topbar-h); left:0; bottom:0; width:var(--side-w); z-index:50;
|
||
background:var(--card); border-right:1px solid var(--line);
|
||
padding:14px 12px 18px; overflow-y:auto; display:flex; flex-direction:column;
|
||
}
|
||
.admin-sidebar .side-label{ font-size:.72rem; font-weight:700; color:var(--muted); padding:10px 14px 6px; }
|
||
.admin-sidebar .tabs{ display:flex !important; flex-direction:column !important; gap:3px !important; flex-wrap:nowrap !important;
|
||
width:100% !important; overflow:visible !important; order:0 !important; margin:0 !important; padding:0 !important; background:none !important; }
|
||
.admin-sidebar .tabs .tab{
|
||
display:flex !important; align-items:center !important; gap:13px !important; width:100% !important; text-align:left !important;
|
||
padding:10px 14px !important; border-radius:11px !important; border:none !important; background:transparent !important;
|
||
color:var(--muted) !important; font-size:.95rem !important; font-weight:700 !important; box-shadow:none !important;
|
||
transform:none !important; white-space:nowrap; transition:background .15s, color .15s !important; margin:0 !important;
|
||
}
|
||
.admin-sidebar .tabs .tab i{ width:20px; text-align:center; font-size:1rem; opacity:.9; }
|
||
.admin-sidebar .tabs .tab:hover{ background:var(--panel) !important; color:var(--text) !important; }
|
||
.admin-sidebar .tabs .tab.active{ background:var(--primary-soft) !important; color:var(--primary) !important; }
|
||
.admin-sidebar .side-foot{ margin-top:auto; padding-top:14px; display:flex; flex-direction:column; gap:8px; }
|
||
.side-discord{ display:block; text-align:center; background:var(--gradient-main); color:#fff !important; text-decoration:none !important;
|
||
font-weight:600; font-size:.9rem; padding:11px; border-radius:8px; }
|
||
.side-discord:hover{ background:var(--primary-dark); }
|
||
.cd-theme-toggle{ display:flex; align-items:center; gap:12px; width:100%; padding:10px 14px; background:none; border:none;
|
||
border-radius:11px; cursor:pointer; font-family:inherit; font-size:.95rem; font-weight:700; color:var(--text); text-align:left; }
|
||
.cd-theme-toggle:hover{ color:var(--primary); background:var(--primary-soft); }
|
||
.cd-theme-toggle svg{ width:20px; height:20px; flex-shrink:0; }
|
||
.side-scrim{ display:none; position:fixed; inset:var(--topbar-h) 0 0 0; background:rgba(0,0,0,.4); z-index:49; }
|
||
.side-scrim.open{ display:block; }
|
||
|
||
/* ── page area ── */
|
||
.content{
|
||
text-align:left !important; max-width:none !important; width:auto !important;
|
||
margin:0 0 0 var(--side-w) !important; padding:calc(var(--topbar-h) + 24px) 32px 56px !important; box-sizing:border-box;
|
||
background:transparent !important; color:var(--text) !important;
|
||
}
|
||
.content h2{ font-size:1.6rem !important; font-weight:700 !important; margin:0 0 6px !important; text-align:left !important; }
|
||
.content h3{ font-size:1.15rem !important; font-weight:700 !important; text-align:left !important; }
|
||
.content p{ color:var(--muted) !important; text-align:left !important; }
|
||
.dashboard-header{ margin-bottom:22px; }
|
||
section.dashboard, .dashboard{ justify-content:start !important; }
|
||
.section-title{ color:var(--text) !important; font-weight:700 !important; }
|
||
|
||
/* ── cards / panels (incl. inline-styled ones) ── */
|
||
.card, .card_2, .stat-card, .fj-card, .themed-form-container, .dashboard > div,
|
||
[style*="var(--table-bg)"], [style*="var(--card-bg)"]{
|
||
background:var(--card) !important; color:var(--text) !important;
|
||
border:1px solid var(--line) !important; border-radius:var(--radius) !important; box-shadow:none !important;
|
||
}
|
||
.card, .card_2{ text-align:left !important; transform:none !important; transition:border-color .15s !important; }
|
||
.card:hover, .card_2:hover{ border-color:var(--primary) !important; transform:none !important; }
|
||
.card .btn, .card button, .card_2 .btn, .card_2 button{ margin-top:14px; }
|
||
.quick-links h2{ margin-top:28px !important; }
|
||
.stat-label{ color:var(--muted) !important; font-size:.72rem !important; font-weight:700 !important; letter-spacing:.04em; text-transform:uppercase; }
|
||
.stat-value{ color:var(--text) !important; font-weight:700 !important; }
|
||
.card h3, .card_2 h3, .card h4, .card_2 h4{ color:var(--text) !important; }
|
||
.card p, .card_2 p{ color:var(--muted) !important; }
|
||
|
||
/* ── buttons (the site's .btn-dl / .btn-login / .btn-danger) ── */
|
||
button, .btn, input[type="button"], input[type="submit"]{ font-family:inherit !important; }
|
||
.content button:not(.side-toggle):not(.icon-btn):not(.tab):not(.switch):not(.cd-accent),
|
||
.popup button, .btn{
|
||
border-radius:8px !important; font-weight:600 !important; font-size:.88rem !important;
|
||
box-shadow:none !important; transform:none !important; text-shadow:none !important; cursor:pointer;
|
||
transition:background .15s, color .15s, border-color .15s !important;
|
||
}
|
||
/* primary: filled accent */
|
||
.btn-primary, .btn-info, .btn-success,
|
||
.content button[style*="var(--card-hover-bg)"], .popup button[style*="var(--card-hover-bg)"],
|
||
.content button:not([style*="background"]):not(.btn):not(.side-toggle):not(.icon-btn):not(.tab):not(.cd-accent):not(.cd-mode),
|
||
#popup-buttons button:first-child{
|
||
background:var(--primary) !important; color:#fff !important; border:1.5px solid var(--primary) !important;
|
||
}
|
||
.btn-primary:hover, .btn-info:hover, .btn-success:hover,
|
||
.content button[style*="var(--card-hover-bg)"]:hover, .popup button[style*="var(--card-hover-bg)"]:hover,
|
||
.content button:not([style*="background"]):not(.btn):not(.side-toggle):not(.icon-btn):not(.tab):not(.cd-accent):not(.cd-mode):hover{
|
||
background:var(--primary-dark) !important; border-color:var(--primary-dark) !important; color:#fff !important;
|
||
}
|
||
/* secondary: outlined, like "Log in" */
|
||
.btn-secondary, .btn-warning, .btn-light, .btn-dark, .btn-edit, .btn-room-view,
|
||
.content button[style*="#555"], .content button[style*="#2c3e50"], .content button[style*="#7f8c8d"],
|
||
.popup button[style*="#555"], #popup-buttons button + button{
|
||
background:transparent !important; color:var(--text) !important; border:1.5px solid var(--line) !important;
|
||
}
|
||
.btn-secondary:hover, .btn-warning:hover, .btn-light:hover, .btn-dark:hover, .btn-edit:hover, .btn-room-view:hover,
|
||
.content button[style*="#555"]:hover, .content button[style*="#2c3e50"]:hover, .content button[style*="#7f8c8d"]:hover{
|
||
border-color:var(--primary) !important; color:var(--primary) !important; background:transparent !important;
|
||
}
|
||
/* danger: outlined red that fills on hover (the site's .btn-danger, in red) */
|
||
.btn-danger, .btn-ban, .btn-delete, .btn-delete-player,
|
||
.content button[style*="#c0392b"], .content button[style*="#e74c3c"], .content button[style*="#d9534f"],
|
||
.popup button[style*="#c0392b"]{
|
||
background:transparent !important; color:var(--danger) !important; border:1.5px solid var(--danger) !important;
|
||
}
|
||
.btn-danger:hover, .btn-ban:hover, .btn-delete:hover, .btn-delete-player:hover,
|
||
.content button[style*="#c0392b"]:hover, .content button[style*="#e74c3c"]:hover, .content button[style*="#d9534f"]:hover{
|
||
background:var(--danger) !important; color:#fff !important;
|
||
}
|
||
/* warning actions (Warn) keep an amber outline */
|
||
.content button[style*="#d68910"], .content button[style*="#f39c12"]{
|
||
background:transparent !important; color:var(--warn) !important; border:1.5px solid var(--warn) !important;
|
||
}
|
||
.content button[style*="#d68910"]:hover{ background:var(--warn) !important; color:#fff !important; }
|
||
.btn{ display:inline-flex !important; align-items:center; justify-content:center; gap:6px;
|
||
padding:8px 16px !important; line-height:1.25 !important; min-height:36px; text-decoration:none !important; }
|
||
.btn-sm{ padding:5px 10px !important; min-height:30px; font-size:.8rem !important; }
|
||
/* player row actions: a compact wrapping grid instead of a 10-button tower */
|
||
.cell-actions{ display:flex !important; flex-wrap:wrap !important; gap:6px !important; min-width:260px; max-width:340px; }
|
||
.cell-actions .btn, .cell-actions button{ padding:5px 10px !important; min-height:30px !important; font-size:.78rem !important; margin:0 !important; width:auto !important; }
|
||
/* Mod-role hiding. MUST stay below .btn/.tab/.cell-actions above: with equal specificity and
|
||
equal !important weight, CSS breaks the tie by SOURCE ORDER (last wins) - placed earlier these
|
||
silently lost to .btn{display:inline-flex !important} and .tab{display:flex !important}, which
|
||
is exactly the bug that shipped first (button computed "flex" even with .mod-only-action's own
|
||
!important rule present and matching). */
|
||
.admin-sidebar .tabs .tab.role-hidden{ display:none !important; }
|
||
.card.role-hidden{ display:none !important; }
|
||
/* A mod's view of the Players table: Platform Id / Device Id / Player Created At / Last Online
|
||
columns hidden (Level and Room/online status stay), and the row actions pared down to just
|
||
View Player, Ban and the new Timeout button - everything else (role toggles, Delete, View Dorm)
|
||
is a dev-only capability a mod was never given. #Players.mod-view is toggled once by
|
||
applyRoleUI(); the column hide is by position so neither render function (_renderPlayerRows,
|
||
searchPlayers) needs its own role check. */
|
||
#Players.mod-view table th:nth-child(4), #Players.mod-view table td:nth-child(4),
|
||
#Players.mod-view table th:nth-child(5), #Players.mod-view table td:nth-child(5),
|
||
#Players.mod-view table th:nth-child(7), #Players.mod-view table td:nth-child(7),
|
||
#Players.mod-view table th:nth-child(8), #Players.mod-view table td:nth-child(8) { display:none !important; }
|
||
#Players.mod-view .dev-only-action{ display:none !important; }
|
||
.mod-only-action{ display:none !important; }
|
||
#Players.mod-view .mod-only-action{ display:inline-flex !important; }
|
||
/* role toggles (Dev / Mod / Screenshare / Support / Influencer): on = soft accent chip */
|
||
.btn-role{ background:transparent !important; color:var(--muted) !important; border:1.5px dashed var(--line) !important; }
|
||
.btn-role:hover{ color:var(--primary) !important; border-color:var(--primary) !important; }
|
||
.btn-role.role-on{ background:var(--primary-soft) !important; color:var(--primary) !important; border:1.5px solid var(--primary) !important; }
|
||
/* a bare .btn with no variant: outlined, like the site's "Log in" */
|
||
.btn:not([class*="btn-"]){ background:transparent !important; color:var(--text) !important; border:1.5px solid var(--line) !important; }
|
||
.btn:not([class*="btn-"]):hover{ border-color:var(--primary) !important; color:var(--primary) !important; }
|
||
/* the site's theme mode buttons (Website/style.css .cd-modes) */
|
||
.cd-modes{ display:flex; gap:8px; }
|
||
.cd-modes .cd-mode{ flex:1; padding:10px 0 !important; background:var(--panel) !important; border:1.5px solid var(--line) !important;
|
||
border-radius:11px !important; font-weight:700 !important; font-size:.85rem !important; color:var(--text) !important; }
|
||
.cd-modes .cd-mode:hover{ border-color:var(--primary) !important; }
|
||
.cd-modes .cd-mode.active{ border-color:var(--primary) !important; background:var(--primary-soft) !important; color:var(--primary) !important; }
|
||
button:disabled, .btn:disabled{ opacity:.5 !important; cursor:not-allowed !important; }
|
||
|
||
/* ── fields (the site's .field input) ── */
|
||
input[type="text"], input[type="number"], input[type="password"], input[type="email"], input[type="search"],
|
||
input[type="url"], input[type="date"], input[type="datetime-local"], input:not([type]), textarea, select, .form-control{
|
||
background:var(--panel) !important; color:var(--text) !important; border:1px solid var(--line) !important;
|
||
border-radius:8px !important; font-family:inherit !important; font-size:.92rem !important; box-shadow:none !important;
|
||
min-height:38px; padding:8px 12px !important; transition:border-color .15s, box-shadow .15s, background .15s;
|
||
}
|
||
textarea{ min-height:80px; }
|
||
input:focus, textarea:focus, select:focus, .form-control:focus{
|
||
outline:none !important; border-color:var(--primary) !important; background:var(--card) !important;
|
||
box-shadow:0 0 0 3px var(--primary-soft) !important;
|
||
}
|
||
input::placeholder, textarea::placeholder{ color:var(--muted) !important; opacity:.7; }
|
||
input[type="checkbox"], input[type="radio"]{ accent-color:var(--primary); width:16px; height:16px; }
|
||
label{ color:var(--text); }
|
||
|
||
/* ── tables ── */
|
||
.table, table{ color:var(--text) !important; background:var(--card) !important; border-radius:var(--radius);
|
||
border-collapse:separate !important; border-spacing:0; overflow:hidden; border:1px solid var(--line) !important; }
|
||
.table th, table th{ background:var(--panel) !important; color:var(--muted) !important; font-size:.78rem; font-weight:700;
|
||
text-transform:uppercase; letter-spacing:.03em; border:none !important; border-bottom:1px solid var(--line) !important; }
|
||
.table td, table td{ border:none !important; border-top:1px solid var(--line) !important; vertical-align:middle !important; color:var(--text) !important; }
|
||
.content table{ display:block; max-width:100%; overflow-x:auto; } /* wide tables scroll in place, not the page */
|
||
.content table > thead, .content table > tbody{ width:100%; }
|
||
.table-striped tbody tr:nth-of-type(odd){ background:transparent !important; }
|
||
.table tbody tr:hover{ background:var(--primary-soft) !important; }
|
||
|
||
/* ── popups ── */
|
||
.popup-overlay{ background:rgba(10,8,20,.5) !important; backdrop-filter:blur(3px); }
|
||
.popup, .popup-content{ background:var(--card) !important; color:var(--text) !important; border:1px solid var(--line) !important;
|
||
border-radius:16px !important; box-shadow:0 12px 40px rgba(0,0,0,.2) !important; }
|
||
.popup h1, .popup h2, .popup h3, .popup h4, .popup p, .popup span, .popup label{ color:var(--text); }
|
||
.popup-header{ border-bottom:1px solid var(--line) !important; }
|
||
|
||
/* ── misc components ── */
|
||
.platform-badge{ background:var(--panel) !important; color:var(--text) !important; border-radius:999px !important; }
|
||
.slider{ background:var(--line) !important; }
|
||
input:checked + .slider{ background:var(--primary) !important; }
|
||
.log-console{ background:#14121f !important; border-color:var(--line) !important; }
|
||
.log-toolbar input[type="text"], .log-toolbar select, .fj-row input{ background:var(--panel) !important; color:var(--text) !important; border:1px solid var(--line) !important; }
|
||
#reportsBadge{ background:var(--danger) !important; }
|
||
.profile-img, .room-img{ border-radius:10px !important; }
|
||
|
||
/* ── room editor (home_page.js) ── */
|
||
.re-grid{ display:grid; grid-template-columns:1fr 1fr; gap:12px 16px; }
|
||
.re-grid label{ display:flex; flex-direction:column; gap:6px; font-size:.82rem; font-weight:600; }
|
||
.re-grid .full{ grid-column:1 / -1; }
|
||
.re-meta{ display:flex; flex-wrap:wrap; gap:8px; margin:6px 0 14px; }
|
||
.re-chip{ background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:3px 10px; font-size:.78rem; color:var(--muted); }
|
||
#room-editor .popup{ text-align:left !important; }
|
||
#room-editor h3{ text-align:left !important; }
|
||
.re-img{ width:100% !important; max-width:none !important; height:auto !important; aspect-ratio:16/9; object-fit:cover;
|
||
border-radius:12px; border:1px solid var(--line); background:var(--panel); display:block; }
|
||
.re-grid label{ text-align:left !important; align-items:stretch; }
|
||
|
||
/* ── phone / tablet: the drawer slides in from the cat button ── */
|
||
@media (max-width: 900px){
|
||
.admin-sidebar{ transform:translateX(-100%); transition:transform .28s cubic-bezier(.4,0,.2,1); z-index:80; box-shadow:none; }
|
||
.admin-sidebar.open{ transform:translateX(0); box-shadow:0 0 40px rgba(0,0,0,.18); }
|
||
.content{ margin-left:0 !important; padding:calc(var(--topbar-h) + 16px) 16px 40px !important; }
|
||
.btn-login .lbl, .admin-brand .admin-pill{ display:none; }
|
||
#you-button, #you-button.btn{ font-size:0 !important; padding:3px !important; }
|
||
#you-button .profile-img2{ margin:0 !important; }
|
||
.re-grid{ grid-template-columns:1fr; }
|
||
.table, table{ display:block; overflow-x:auto; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce){ *{ transition:none !important; animation:none !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>
|
||
|
||
<!-- Mic ban: Rec Room's voice-moderation ban. The player stays in the game with their mic off. -->
|
||
<div class="popup-overlay" id="micban-popup">
|
||
<div class="popup">
|
||
<h2 id="micban-title">Mic ban</h2>
|
||
<p style="opacity:.8; margin-top:0;">They keep playing and can hear everyone, but their microphone is switched off until it runs out or you lift it.</p>
|
||
<label style="display:block; text-align:left; margin:10px 0 4px;">How long</label>
|
||
<div style="display:flex; gap:8px; align-items:center;">
|
||
<input id="micban-amount" type="number" min="1" step="1" value="1" style="width:90px; padding:9px; border-radius:8px; box-sizing:border-box;" />
|
||
<select id="micban-unit" style="flex:1; padding:9px; border-radius:8px;">
|
||
<option value="1">minutes</option>
|
||
<option value="60" selected>hours</option>
|
||
<option value="1440">days</option>
|
||
<option value="10080">weeks</option>
|
||
</select>
|
||
</div>
|
||
<label style="display:flex; align-items:center; gap:8px; margin:10px 0 0; text-align:left; cursor:pointer;">
|
||
<input id="micban-permanent" type="checkbox" onchange="document.getElementById('micban-amount').disabled = document.getElementById('micban-unit').disabled = this.checked;" />
|
||
Permanent (the in-game timer keeps restarting until you lift it)
|
||
</label>
|
||
<label for="micban-reason" style="display:block; text-align:left; margin:12px 0 4px;">Reason (shown to the player)</label>
|
||
<input id="micban-reason" type="text" maxlength="200" placeholder="e.g. Mic spam" style="width:100%; padding:9px; border-radius:8px; box-sizing:border-box;" />
|
||
<div class="button-container" style="margin-top:16px;">
|
||
<button class="confirm-btn" id="micban-submit" onclick="submitMicBan()">Mic ban</button>
|
||
<button class="cancel-btn" onclick="closePopup('micban-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>
|
||
<!-- ban status (viewPlayer fills it; it used to throw because this element was missing) -->
|
||
<div id="player-popup-banstatus" class="ban-status" style="display:none; margin:10px 0;"></div>
|
||
|
||
|
||
<!-- BUTTONS -->
|
||
<div id="popup-buttons"></div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- Top bar and side drawer: the main website's (Website/index.html) -->
|
||
<div class="navbar">
|
||
<div class="top-left">
|
||
<button class="side-toggle" id="adminBurger" type="button" aria-label="Menu">
|
||
<img src="/assets/cat-icon.png" alt="" class="burger-cat">
|
||
</button>
|
||
<a class="admin-brand" href="/admin/home/v4"><span>Campus</span><span class="admin-pill">ADMIN</span></a>
|
||
</div>
|
||
<div class="top-right">
|
||
<button class="icon-btn theme-icon-btn" type="button" aria-label="Switch theme"></button>
|
||
<a class="btn-login" href="/" target="_blank" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square"></i><span class="lbl">View site</span></a>
|
||
<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>
|
||
<a class="icon-btn" href="/admin/logout" title="Log out" aria-label="Log out"><i class="fa-solid fa-right-from-bracket"></i></a>
|
||
</div>
|
||
</div>
|
||
|
||
<aside class="admin-sidebar" id="adminSidebar">
|
||
<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="Challenges"><i class="fa-solid fa-list-check"></i> Challenges</button>
|
||
<button class="tab" data-tab="Stores"><i class="fa-solid fa-store"></i> Stores</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="BanLogs"><i class="fa-solid fa-gavel"></i> Ban Logs</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="DbEditor"><i class="fa-solid fa-table-list"></i> DB Editor</button>
|
||
<button class="tab" data-tab="RecflareCdn"><i class="fa-solid fa-cloud-arrow-down"></i> RecFlare CDN</button>
|
||
<button class="tab" data-tab="RoomEditor"><i class="fa-solid fa-sliders"></i> Room Editor</button>
|
||
<button class="tab" data-tab="ArchiveRooms"><i class="fa-solid fa-box-archive"></i> Archive</button>
|
||
<button class="tab" data-tab="Videos"><i class="fa-solid fa-film"></i> Videos</button>
|
||
<button class="tab" data-tab="Chat"><i class="fa-solid fa-comments"></i> Chat</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 class="side-foot">
|
||
<button class="cd-theme-toggle" type="button"></button>
|
||
<a class="side-discord" href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||
</div>
|
||
</aside>
|
||
<div class="side-scrim" id="adminScrim"></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" id="dashCardLogs">
|
||
<h3>Logs</h3>
|
||
<p id="logs">Check out logs</p>
|
||
<button class="btn btn-info" onclick="setup_html_show('Log')">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" id="dashCardRooms">
|
||
<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" id="dashCardClubs">
|
||
<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" id="dashCardInventions">
|
||
<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-secondary" 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-primary" 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://camprec.net/api/site/social/<provider>/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 — comma separated, several allowed)</label>
|
||
<textarea id="editsteamid" rows="2" placeholder="76561198000000001, 76561198000000002 — 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 — 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 — 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;">—</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>
|
||
|
||
<label style="display:block; margin-bottom:4px; opacity:.85;">Bio lock</label>
|
||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:10px;">
|
||
<span id="editLockState_bio" style="opacity:.8;">—</span>
|
||
<button id="editLockBtn_bio" 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_profile_lock('bio');">Lock bio</button>
|
||
</div>
|
||
|
||
<label style="display:block; margin-bottom:4px; opacity:.85;">Profile picture lock</label>
|
||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:10px;">
|
||
<span id="editLockState_picture" style="opacity:.8;">—</span>
|
||
<button id="editLockBtn_picture" 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_profile_lock('picture');">Lock picture</button>
|
||
</div>
|
||
|
||
<label style="display:block; margin-bottom:4px; opacity:.85;">Banner lock</label>
|
||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:10px;">
|
||
<span id="editLockState_banner" style="opacity:.8;">—</span>
|
||
<button id="editLockBtn_banner" 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_profile_lock('banner');">Lock banner</button>
|
||
</div>
|
||
|
||
<label style="display:block; margin-bottom:4px; opacity:.85;">Two-factor authentication</label>
|
||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:10px;">
|
||
<span id="edit2faState" style="opacity:.8;">—</span>
|
||
<button id="edit2faBtn" type="button" style="display:none; background:#c0392b; color:white; padding:8px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;" onclick="edit2faDisable();">Disable 2FA</button>
|
||
</div>
|
||
|
||
<label style="display:block; margin-bottom:4px; opacity:.85;">Junior account</label>
|
||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:10px;">
|
||
<span id="editJuniorState" style="opacity:.8;">—</span>
|
||
<button id="editJuniorBtn" 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_junior();">Make junior</button>
|
||
</div>
|
||
<p style="opacity:.65; font-size:13px; margin:0 0 10px;">
|
||
Junior accounts get the under-13 restrictions. Applied live — no relog. A
|
||
moderator's in-game <i>Underage</i> report does this automatically.
|
||
</p>
|
||
<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 — 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–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 — 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>
|
||
|
||
|
||
|
||
|
||
|
||
<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>
|
||
<div style="background:var(--table-bg); border-radius:8px; padding:12px; margin-bottom:14px;">
|
||
<b>Import an image</b>
|
||
<span style="opacity:.7; font-size:13px;"> — any PNG or JPEG. Imports skip the upload anti-cheat (only game-made photos are accepted from players).</span>
|
||
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:10px;">
|
||
<input type="file" id="imgImportFile" accept="image/png,image/jpeg" />
|
||
<input type="text" id="imgImportOwner" placeholder="Owner id or @name (blank = you)" style="padding:8px; border:1px solid #ccc; border-radius:5px; min-width:210px;" />
|
||
<input type="text" id="imgImportDesc" placeholder="Description (optional)" style="padding:8px; border:1px solid #ccc; border-radius:5px; min-width:210px;" />
|
||
<input type="number" id="imgImportRoom" placeholder="Room id (optional)" style="padding:8px; border:1px solid #ccc; border-radius:5px; width:150px;" />
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="imgImportPublic" checked /> Public</label>
|
||
<button onclick="imgImport();" style="background:var(--card-hover-bg); color:white; padding:8px 14px; border:none; cursor:pointer; border-radius:5px;">Import</button>
|
||
</div>
|
||
<div id="imgImportMsg" style="margin-top:8px; opacity:.85;"></div>
|
||
</div>
|
||
<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="Challenges">
|
||
<h2>Challenges</h2>
|
||
<p style="opacity:.75; margin-top:0;">The <b>daily challenges</b> on the watch (Challenges › Daily). Each one pays XP when it completes; finishing all of them sends a <b>game request</b> (the three-card reward picker), once per player per day. The day resets at <b>midnight UTC</b>. Players get a changed set on their next login.</p>
|
||
|
||
<div id="chalToday" style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:16px;"></div>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;">Daily challenges</h3>
|
||
<span id="chalSource" style="opacity:.7; font-size:13px;"></span>
|
||
</div>
|
||
<div style="display:flex; gap:18px; align-items:center; flex-wrap:wrap; margin:14px 0;">
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="chalReward" /> Send a game request when all are done</label>
|
||
<label style="display:flex; gap:6px; align-items:center;">XP per challenge <input type="number" id="chalXp" min="0" max="100000" style="width:90px; padding:6px; border:1px solid #ccc; border-radius:5px;" /></label>
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="chalPerDay" onchange="chalRender();" /> Different set each day</label>
|
||
</div>
|
||
<div id="chalSets" style="display:flex; flex-direction:column; gap:12px;"></div>
|
||
<div style="margin-top:14px; display:flex; gap:10px; flex-wrap:wrap;">
|
||
<button onclick="chalSave();" style="background:var(--card-hover-bg); color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Save</button>
|
||
<button onclick="chalLoad();" style="background:#555; color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Discard changes</button>
|
||
<button onclick="chalDefaults();" style="background:#7f8c8d; color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Reset to defaults</button>
|
||
</div>
|
||
<div id="chalMsg" style="margin-top:10px; opacity:.85;"></div>
|
||
<p style="opacity:.6; font-size:13px; margin:12px 0 0;">Types marked <b>tutorial only</b> fire just during the new-player tutorial, so nobody can complete them afterwards. Activity types (Dodgeball wins, Quest games, ...) only count in that activity. With a different set each day, every day needs the same number of challenges.</p>
|
||
</div>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<h3 style="margin:0 0 10px;">Player progress</h3>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="chalPlayer" placeholder="Player id or @username" style="padding:9px; font-size:15px; border:1px solid #ccc; border-radius:5px; min-width:230px;" onkeydown="if(event.key==='Enter')chalPlayerLoad();" />
|
||
<button onclick="chalPlayerLoad();" style="background:var(--card-hover-bg); color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Look up</button>
|
||
<button onclick="chalPlayerReset();" style="background:#a04000; color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Reset their challenges</button>
|
||
</div>
|
||
<div id="chalPlayerOut" style="margin-top:12px;"></div>
|
||
</div>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;">Limited Time Events</h3>
|
||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||
<button onclick="evNew(false);" style="background:var(--card-hover-bg); color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">New event</button>
|
||
<button onclick="evNew(true);" style="background:#555; color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">New from Summer Party template</button>
|
||
</div>
|
||
</div>
|
||
<p style="opacity:.75; margin:8px 0 12px;">The watch's <b>Limited Time Challenge</b> (Summer Party style): a reward track with a free row and an RR+ row. Players earn event XP by spending time in <b>public rooms</b> — <span id="evRule"></span>. Start new events as <b>Developers only</b>: the game's event screen is still being matched to this server, so test it yourself before switching it on for everyone.</p>
|
||
<div id="evList" style="display:flex; flex-direction:column; gap:8px;"></div>
|
||
<div id="evEditor" style="display:none; margin-top:14px; border:1px solid rgba(128,128,128,.35); border-radius:8px; padding:14px;"></div>
|
||
<div id="evMsg" style="margin-top:10px; opacity:.85;"></div>
|
||
<div style="margin-top:14px; border-top:1px solid rgba(128,128,128,.25); padding-top:12px;">
|
||
<b>Test tools</b>
|
||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px;">
|
||
<select id="evToolEvent" style="padding:7px; border:1px solid #ccc; border-radius:5px; min-width:200px;"></select>
|
||
<input type="text" id="evToolPlayer" placeholder="Player id or @username" style="padding:7px; border:1px solid #ccc; border-radius:5px; min-width:200px;" />
|
||
<button onclick="evToolLook();" style="background:#555; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Look up</button>
|
||
<input type="number" id="evToolXp" value="100" style="padding:7px; border:1px solid #ccc; border-radius:5px; width:90px;" />
|
||
<button onclick="evToolXp();" style="background:var(--card-hover-bg); color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Give XP</button>
|
||
<button onclick="evToolReset();" style="background:#a04000; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Reset record</button>
|
||
</div>
|
||
<div id="evToolOut" style="margin-top:8px; opacity:.85;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;">Weekly challenges</h3>
|
||
<a href="/challenges" target="_blank" rel="noopener" style="font-size:13px; opacity:.8;">Open the website page ↗</a>
|
||
</div>
|
||
<p style="opacity:.75; margin:8px 0 12px;">Shown on the website at <b>/challenges</b> (the game has no weekly screen). Five new challenges every <b>Wednesday 21:00 UTC</b>, counted from games players <b>finish</b>. Finishing <b id="wkNeed">3</b> sends the week's reward as an in-game gift. Pick any store <b>skin</b> or <b>clothing</b> item as the reward; with none picked, a random store skin is used. Someone who already owns the reward gets a 500-token box instead.</p>
|
||
<div style="display:flex; gap:8px; margin-bottom:12px;">
|
||
<button id="wkTabCur" onclick="wkShow('current');" style="background:var(--card-hover-bg); color:white; padding:7px 14px; font-size:14px; border:none; cursor:pointer; border-radius:5px;">This week</button>
|
||
<button id="wkTabNext" onclick="wkShow('next');" style="background:#555; color:white; padding:7px 14px; font-size:14px; border:none; cursor:pointer; border-radius:5px;">Next week</button>
|
||
</div>
|
||
<div id="wkWeek"></div>
|
||
<div id="wkPicker" style="display:none; margin-top:12px; border:1px dashed rgba(128,128,128,.5); border-radius:8px; padding:10px;">
|
||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="wkQ" placeholder="Search store items" onkeydown="if(event.key==='Enter')wkSearch();" style="padding:7px; border:1px solid #ccc; border-radius:5px; min-width:220px;" />
|
||
<select id="wkKind" onchange="wkSearch();" style="padding:7px; border:1px solid #ccc; border-radius:5px;"><option value="">Skins and clothing</option><option value="skin">Skins only</option><option value="clothing">Clothing only</option></select>
|
||
<button onclick="wkSearch();" style="background:var(--card-hover-bg); color:white; padding:7px 14px; font-size:14px; border:none; cursor:pointer; border-radius:5px;">Search</button>
|
||
<button onclick="document.getElementById('wkPicker').style.display='none';" style="background:#7f8c8d; color:white; padding:7px 14px; font-size:14px; border:none; cursor:pointer; border-radius:5px;">Close</button>
|
||
</div>
|
||
<div id="wkPickOut" style="display:flex; flex-wrap:wrap; gap:8px; margin-top:10px;"></div>
|
||
</div>
|
||
<div id="wkMsg" style="margin-top:10px; opacity:.85;"></div>
|
||
<div style="margin-top:14px; border-top:1px solid rgba(128,128,128,.25); padding-top:12px; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="wkPlayer" placeholder="Player id or @username" onkeydown="if(event.key==='Enter')wkPlayerLoad();" style="padding:7px; border:1px solid #ccc; border-radius:5px; min-width:220px;" />
|
||
<button onclick="wkPlayerLoad();" style="background:#555; color:white; padding:7px 14px; font-size:14px; border:none; cursor:pointer; border-radius:5px;">Their progress</button>
|
||
<div id="wkPlayerOut" style="width:100%; margin-top:6px;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content" id="Stores">
|
||
<h2>Stores</h2>
|
||
<p style="opacity:.75; margin-top:0;">Edit what the in-game <b>stores</b> sell. Pick a store, add any item, set prices and put items in <b>sets</b> by set id number (items with the same set id show together in the store). Changes go live straight away - players see them next time they open that store. The original store files are never changed: <b>Restore</b> puts an edited or removed item back to how it was.</p>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<label style="display:flex; gap:6px; align-items:center;">Store
|
||
<select id="stSelect" onchange="stLoad();" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; min-width:260px;"></select>
|
||
</label>
|
||
<span id="stSummary" style="opacity:.75;"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<h3 style="margin:0 0 10px;">Add an item</h3>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="stSearch" placeholder="Search items by name..." oninput="stSearchSoon();" style="padding:9px; font-size:15px; border:1px solid #ccc; border-radius:5px; min-width:300px; flex:1;" />
|
||
</div>
|
||
<div id="stResults" style="display:flex; flex-direction:column; gap:6px; margin-top:10px; max-height:280px; overflow:auto;"></div>
|
||
<div id="stPicked" style="margin-top:12px; opacity:.85;">Search above and click an item to pick it.</div>
|
||
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center; margin-top:10px;">
|
||
<label style="display:flex; gap:6px; align-items:center;">Price <input type="number" id="stAddPrice" min="0" value="500" style="width:100px; padding:6px; border:1px solid #ccc; border-radius:5px;" /></label>
|
||
<label style="display:flex; gap:6px; align-items:center;">Currency <select id="stAddCurrency" style="padding:6px; border:1px solid #ccc; border-radius:5px;"></select></label>
|
||
<label style="display:flex; gap:6px; align-items:center;">Set id <input type="number" id="stAddSetId" min="1" list="stSetsList" placeholder="none" oninput="stSetIdTyped('stAddSetId','stAddSetName');" style="width:90px; padding:6px; border:1px solid #ccc; border-radius:5px;" /></label>
|
||
<label style="display:flex; gap:6px; align-items:center;">Set name <input type="text" id="stAddSetName" placeholder="(new set name)" style="width:200px; padding:6px; border:1px solid #ccc; border-radius:5px;" /></label>
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="stAddFeatured" /> Featured</label>
|
||
<button onclick="stAdd();" style="background:var(--card-hover-bg); color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Add to store</button>
|
||
</div>
|
||
<datalist id="stSetsList"></datalist>
|
||
<div id="stAddMsg" style="margin-top:10px; opacity:.85;"></div>
|
||
</div>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;">Items in this store</h3>
|
||
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||
<input type="text" id="stFilter" placeholder="Filter by name, set or id" oninput="stRender();" style="padding:7px; border:1px solid #ccc; border-radius:5px; min-width:220px;" />
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="stGroup" checked onchange="stRender();" /> Group by set</label>
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="stShowRemoved" onchange="stRender();" /> Show removed</label>
|
||
</div>
|
||
</div>
|
||
<div id="stMsg" style="margin:10px 0; opacity:.85;"></div>
|
||
<div id="stItems" style="display:flex; flex-direction:column; gap:10px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content" id="RoomEditor">
|
||
<h2>Room Editor</h2>
|
||
<p style="opacity:.75; margin-top:0;">One room, everything: the platforms it is shown for, each subroom's settings, its save file (download, upload a new one, restore an older one), what each role may do in it, and moving subrooms into another room.</p>
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="rteQ" placeholder="Room name or id" onkeydown="if(event.key==='Enter')rteFind();" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; flex:1; min-width:260px;" />
|
||
<button onclick="rteFind();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Find</button>
|
||
</div>
|
||
<div id="rteFound" style="margin-top:10px;"></div>
|
||
</div>
|
||
<div id="rteMsg" style="margin:6px 0; min-height:20px;"></div>
|
||
<div id="rteBody"></div>
|
||
</div>
|
||
|
||
<div class="content" id="Videos">
|
||
<h2>Videos</h2>
|
||
<p style="opacity:.75; margin-top:0;">MP4 files for the Remote Video Player. Upload one, then copy its address into the player's Video Url. H.264 video with AAC audio plays most reliably.</p>
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input type="file" id="vidFile" accept=".mp4,video/mp4">
|
||
<input type="text" id="vidName" placeholder="Name (optional, ends in .mp4)" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; width:240px;">
|
||
<label><input type="checkbox" id="vidReplace"> replace if it exists</label>
|
||
<button onclick="vidUpload();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Upload</button>
|
||
</div>
|
||
<div id="vidProg" style="margin-top:8px; min-height:20px;"></div>
|
||
</div>
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<b>From YouTube</b> <span style="opacity:.75;">- only videos whose uploader chose Creative Commons (CC BY). Anything else is refused. Fetched videos are deleted after 3 days; limited to a few per hour.</span>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-top:8px;">
|
||
<input type="text" id="vidYt" placeholder="https://www.youtube.com/watch?v=..." onkeydown="if(event.key==='Enter')vidYoutube();" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; width:420px; max-width:100%;">
|
||
<button id="vidYtBtn" onclick="vidYoutube();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Fetch</button>
|
||
</div>
|
||
</div>
|
||
<div id="vidBody"></div>
|
||
</div>
|
||
<div class="content" id="Chat">
|
||
<h2>Chat</h2>
|
||
<p style="opacity:.75; margin-top:0;">Every DM and group chat - the same ones the game client and the website's <code>/messages</code> page use. Read-only (moderation): nothing here sends a message or changes a thread.</p>
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="chatQ" placeholder="Player username or id, or search message text..." onkeydown="if(event.key==='Enter')chatLoad();" style="flex:1 1 320px; padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px;">
|
||
<button onclick="chatLoad();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Search</button>
|
||
<span id="chatNote" style="opacity:.7;"></span>
|
||
</div>
|
||
<div style="display:flex; gap:16px; align-items:flex-start;">
|
||
<div id="chatThreads" style="flex:1 1 360px; max-width:420px; background:var(--table-bg); border-radius:10px; padding:10px; max-height:70vh; overflow-y:auto;"></div>
|
||
<div id="chatTranscriptWrap" style="flex:2 1 500px; background:var(--table-bg); border-radius:10px; padding:16px; max-height:70vh; overflow-y:auto; min-height:120px;">
|
||
<div style="opacity:.6;">Pick a conversation on the left.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="content" id="ArchiveRooms">
|
||
<h2>Archive</h2>
|
||
<p style="opacity:.75; margin-top:0;">The rooms on <code>archive.splootybean.com/Rooms</code>. <b>Import</b> downloads a room's files for you - its saved build (the <code>.room</code> file of each subroom), its picture - and adds it to Campus as a new room owned by you. The room's baked <code>AssetBundles</code> (its pre-built geometry, roughly 110 MB per room for PC + Quest) are downloaded too, for the platforms you pick here:</p>
|
||
<div style="margin:0 0 12px 0;"><label>Asset bundles: <select id="arcBundles" style="padding:6px; font-size:15px; border:1px solid #ccc; border-radius:5px;">
|
||
<option value="pc+quest" selected>PC + Quest (recommended)</option><option value="pc">PC only</option><option value="quest">Quest only</option>
|
||
<option value="all">every platform and version (several hundred MB per room)</option><option value="none">none - the game bakes the geometry itself</option></select></label></div>
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<h3 style="margin-top:0;">Import by name or address</h3>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="arcName" placeholder="MakeItToMidnight or https://archive.splootybean.com/Rooms/MakeItToMidnight/" onkeydown="if(event.key==='Enter')arcImportTyped();" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; flex:1; min-width:300px;" />
|
||
<button onclick="arcImportTyped();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Import</button>
|
||
</div>
|
||
<div id="arcTypedOut" style="margin-top:10px;"></div>
|
||
</div>
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;">Rooms in the archive</h3>
|
||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||
<button onclick="arcLoad(true);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Reload</button>
|
||
<button id="arcAll" onclick="arcImportAll();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;" disabled>Import everything shown</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-top:10px;">
|
||
<input type="text" id="arcQ" placeholder="Search rooms" oninput="arcRender();" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; flex:1; min-width:260px;" />
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="arcHideDone" checked onchange="arcRender();" /> hide ones already imported</label>
|
||
</div>
|
||
<div id="arcSummary" style="margin-top:10px; opacity:.85;">Open this tab to load the archive.</div>
|
||
<div id="arcBulk" style="margin-top:6px; opacity:.85;"></div>
|
||
<div id="arcList" style="margin-top:6px; max-height:720px; overflow:auto;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content" id="RecflareCdn">
|
||
<h2>RecFlare CDN</h2>
|
||
<p style="opacity:.75; margin-top:0;">What <code>cdn.recflare.net</code> (the RecFlare server's public file store) holds, and pulling files from it into this server. It has <b>no list of its files</b> - it only answers for a name you give it - so look a file up by name, or scan for the room saves this server needs but doesn't have (the rooms that load empty, like Run The Block and My Little Monsters) and restore the ones RecFlare has. A restored room save works straight away. Room files are named like <code>9zwumqm1vehgto2fj12y2yq7e.room</code>; avatar bundles like <code>a1a2taim2pwp2g9wme612ji8l.assetbundle</code> (Quest builds: <code>quest/<name></code>).</p>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<h3 style="margin-top:0;">Look up a file</h3>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<select id="rfKind" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px;">
|
||
<option value="room">room</option><option value="avatar">avatar</option><option value="invention">invention</option>
|
||
<option value="data">data</option><option value="config">config</option><option value="sigs">sigs</option>
|
||
</select>
|
||
<input type="text" id="rfName" placeholder="file name, e.g. 9zwumqm1vehgto2fj12y2yq7e.room" onkeydown="if(event.key==='Enter')rfLookup();" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; flex:1; min-width:280px; font-family:monospace;" />
|
||
<button onclick="rfLookup();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Look up</button>
|
||
</div>
|
||
<div style="opacity:.7; margin-top:6px; font-size:13px;">Several at once: put one name per line or separate them with commas.</div>
|
||
<div id="rfLookupOut" style="margin-top:12px;"></div>
|
||
</div>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;">RecFlare rooms and their files</h3>
|
||
<button onclick="rfRooms(true);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Reload from RecFlare</button>
|
||
</div>
|
||
<p style="opacity:.75; margin:6px 0 10px 0;">Every public room on RecFlare, with the files its saves use (the CDN's own file list doesn't exist - this is read from RecFlare's rooms service). <b>Import</b> brings the whole room into Campus as a new room owned by you: its save files, its picture, and a line in the description crediting who made it on RecFlare. A name that's taken here gets <code>_RF</code> added.</p>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="rfRoomQ" placeholder="Search rooms or file names" oninput="rfRoomsRender();" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; flex:1; min-width:260px;" />
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="rfRoomFilesOnly" checked onchange="rfRoomsRender();" /> only rooms with save files</label>
|
||
</div>
|
||
<div id="rfRoomsSummary" style="margin-top:10px; opacity:.85;">Open this tab to load RecFlare's rooms.</div>
|
||
<div id="rfRoomsList" style="margin-top:6px; max-height:720px; overflow:auto;"></div>
|
||
</div>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;">Missing room saves</h3>
|
||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||
<button onclick="rfScan(true);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Scan again</button>
|
||
<button id="rfRestoreAll" onclick="rfRestoreAll();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;" disabled>Restore everything RecFlare has</button>
|
||
</div>
|
||
</div>
|
||
<div id="rfScanSummary" style="margin-top:10px; opacity:.85;">Open this tab to scan.</div>
|
||
<div id="rfScanRows" style="margin-top:10px; max-height:640px; overflow:auto;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content" id="DbEditor">
|
||
<h2>DB Editor</h2>
|
||
<p style="opacity:.75; margin-top:0;">View and edit the server's databases directly (backups are on the <b>Database</b> tab). Pick a database and a collection, find a document, change its JSON and save. <b>Changes are live immediately and there is no undo button</b> - every save and delete is written to <code>Data/db-editor-audit.jsonl</code> with the document as it was before, so a mistake can be put back by hand. Passwords, tokens and keys show as <code>(hidden)</code> and are kept as they are when you save. Some data is also held in the server's memory, so a change there may only show after that part reloads or the server restarts.</p>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<label style="display:flex; gap:6px; align-items:center;">Database
|
||
<select id="dbSelect" onchange="dbPickDb();" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; min-width:220px;"></select></label>
|
||
<label style="display:flex; gap:6px; align-items:center;">Collection
|
||
<select id="dbCol" onchange="dbBrowse(0);" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; min-width:220px;"></select></label>
|
||
<span id="dbAbout" style="opacity:.7;"></span>
|
||
</div>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-top:12px;">
|
||
<input type="text" id="dbQ" placeholder="Search text in documents (e.g. a username or id)" onkeydown="if(event.key==='Enter')dbBrowse(0);" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; flex:1; min-width:240px;" />
|
||
<input type="text" id="dbWhere" placeholder="or a query, e.g. $.Username = 'Kyle'" onkeydown="if(event.key==='Enter')dbBrowse(0);" style="padding:8px; font-size:15px; border:1px solid #ccc; border-radius:5px; flex:1; min-width:240px; font-family:monospace;" />
|
||
<button onclick="dbBrowse(0);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Search</button>
|
||
<button onclick="document.getElementById('dbQ').value='';document.getElementById('dbWhere').value='';dbBrowse(0);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Clear</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex; gap:16px; flex-wrap:wrap; align-items:flex-start;">
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; flex:1 1 360px; min-width:0;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;">Documents</h3>
|
||
<div style="display:flex; gap:8px; align-items:center;">
|
||
<button onclick="dbBrowse(_dbSkip - 50);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px; padding:6px 12px;">‹ Prev</button>
|
||
<span id="dbPage" style="opacity:.8;"></span>
|
||
<button onclick="dbBrowse(_dbSkip + 50);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px; padding:6px 12px;">Next ›</button>
|
||
</div>
|
||
</div>
|
||
<div id="dbRows" style="display:flex; flex-direction:column; gap:6px; margin-top:10px; max-height:620px; overflow:auto;"></div>
|
||
</div>
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; flex:1 1 460px; min-width:0;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
||
<h3 style="margin:0;" id="dbDocTitle">No document open</h3>
|
||
<button onclick="dbNew();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px; padding:6px 12px;">New document</button>
|
||
</div>
|
||
<textarea id="dbJson" spellcheck="false" placeholder="Click a document on the left to open it here." style="width:100%; box-sizing:border-box; min-height:460px; margin-top:10px; font-family:monospace; font-size:13px; padding:10px; border:1px solid #ccc; border-radius:5px; resize:vertical;"></textarea>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:10px;">
|
||
<button onclick="dbSave();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Save</button>
|
||
<button onclick="dbOpen(_dbDocId);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Reload</button>
|
||
<button onclick="dbDelete();" style="background:#c0392b; color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Delete</button>
|
||
</div>
|
||
<div id="dbMsg" style="margin-top:10px; opacity:.9;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content" id="BanLogs">
|
||
<h2>Ban Logs</h2>
|
||
<p style="opacity:.75; margin-top:0;">Every moderation action, newest first: bans from this panel, from report actions, from staff reporting in-game (1-hour timeouts), from chat commands and the upload guard, plus unbans, kicks, warnings, vote kicks and room bans. Filter by a player to see everything done <b>to</b> or <b>by</b> them.</p>
|
||
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px;">
|
||
<select id="banLogsKinds" onchange="banLogsLoad(true);" style="padding:9px; font-size:15px; border-radius:5px;">
|
||
<option value="">All actions</option>
|
||
<option value="ban,report-ban,report-timeout,chat-ban,upload-ban,votekick" selected>Bans & timeouts</option>
|
||
<option value="unban,roomunban">Unbans</option>
|
||
<option value="roomban,roomunban">Room bans</option>
|
||
<option value="kick,modkick,votekick">Kicks</option>
|
||
<option value="warning">Warnings</option>
|
||
<option value="mic-ban,mic-unban">Mic bans</option>
|
||
</select>
|
||
<input type="text" id="banLogsPlayer" placeholder="Player id or @username" style="padding:9px; font-size:15px; border:1px solid #ccc; border-radius:5px; min-width:230px;" onkeydown="if(event.key==='Enter')banLogsLoad(true);" />
|
||
<button onclick="banLogsLoad(true);" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Refresh</button>
|
||
</div>
|
||
<div id="banLogsSummary" style="opacity:.8; margin-bottom:12px;"></div>
|
||
<div id="banLogsList" style="display:flex; flex-direction:column; gap:8px;"></div>
|
||
<button id="banLogsMore" onclick="banLogsLoad(false);" style="display:none; margin-top:12px; background:#555; color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Load more</button>
|
||
</div>
|
||
|
||
<div class="content" id="Staff">
|
||
<h2>Staff & 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 — 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 — 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 — 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>
|
||
|
||
<h3 style="margin-top:30px;">Skins</h3>
|
||
<p style="opacity:.75; margin-top:0;">Every tool skin in <code>Equipments.json</code> (340), sold in the store or not. Uses the <b>player id</b> and <b>message</b> above. Skins tagged <b>in store</b> arrive in a box with its store art; the rest arrive as a plain box. Either way the skin is in their locker the moment you give it. Enter a player id first to see which ones they already own.</p>
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:12px; display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input type="text" id="skinSearchBox" placeholder="Search skins (e.g. paintball, frisbee, marker)..." style="flex:1; min-width:240px;" oninput="skinSearchLive();" onkeydown="if(event.key==='Enter'){event.preventDefault();skinSearch();}" />
|
||
<select id="skinMode">
|
||
<option value="box">Gift box (opens in-game)</option>
|
||
<option value="locker">Straight to the locker</option>
|
||
</select>
|
||
<button class="btn btn-primary" onclick="skinSearch();">Search</button>
|
||
</div>
|
||
<div id="skinSummary" style="opacity:.8; margin-bottom:12px;"></div>
|
||
<div id="skinResults" 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: …</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–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× 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>
|
||
|
||
<!-- ================= TWO-FACTOR AUTHENTICATION ================= -->
|
||
<!-- For THIS admin's own account only (AdminController.cs /admin/api/2fa/*). An authenticator app (Google
|
||
Authenticator, Authy, 1Password, ...) code, checked on top of the password at login. -->
|
||
<div class="card" style="margin-bottom:18px;">
|
||
<h3><i class="fa-solid fa-shield-halved"></i> Two-Factor Authentication</h3>
|
||
<p id="secState" style="opacity:.85; margin:6px 0 12px;">Loading…</p>
|
||
|
||
<div id="secOff">
|
||
<button class="btn btn-info" onclick="secBeginSetup();">Turn on 2FA</button>
|
||
</div>
|
||
|
||
<div id="secSetup" style="display:none;">
|
||
<p style="opacity:.85;">Add this to your authenticator app (Google Authenticator, Authy, 1Password, ...), then enter the 6-digit code it shows.</p>
|
||
<p style="margin:8px 0;">Secret key: <code id="secSecret" style="user-select:all; background:rgba(0,0,0,.25); padding:3px 8px; border-radius:5px;"></code></p>
|
||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||
<input id="secCode" type="text" inputmode="numeric" placeholder="123456" style="width:140px; 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="secConfirm();">Confirm</button>
|
||
<button class="btn" onclick="secCancelSetup();">Cancel</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="secRecovery" style="display:none;">
|
||
<p style="color:#f3c969;"><i class="fa-solid fa-triangle-exclamation"></i> 2FA is on. Save these recovery codes somewhere safe - each works once, instead of a code from your app, if you lose your phone. They will not be shown again.</p>
|
||
<pre id="secRecoveryList" style="background:rgba(0,0,0,.3); padding:12px; border-radius:8px; line-height:1.8;"></pre>
|
||
<button class="btn" onclick="document.getElementById('secRecovery').style.display='none'; secLoad();">Done</button>
|
||
</div>
|
||
|
||
<div id="secOn" style="display:none;">
|
||
<p style="opacity:.85;">2FA is on for your account. You'll be asked for a code from your app every time you log in.</p>
|
||
<button class="btn btn-danger" onclick="secDisable();">Turn off 2FA</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 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…</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>
|
||
|
||
<!-- ================= NORMAL PLAYER WHITELIST (launcher) ================= -->
|
||
<!-- Steam ids proven by the Campus launcher (LauncherPlayers). Steam logins only. -->
|
||
<div class="card" style="margin-bottom:18px;">
|
||
<h3><i class="fa-brands fa-steam"></i> Normal Player Whitelist (launcher)</h3>
|
||
<p id="launcherPlayersState" style="opacity:.85; margin:6px 0 12px;">Loading…</p>
|
||
<div style="display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:12px;">
|
||
<button class="btn" id="launcherReqBtn" onclick="launcherRequiredToggle()">…</button>
|
||
<button class="btn" onclick="launcherPlayersLoad()">Refresh</button>
|
||
<input id="launcherPlayersFilter" type="text" placeholder="Filter by name or SteamID" oninput="launcherPlayersShow()"
|
||
style="flex:1 1 220px; min-width:180px; 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;">
|
||
</div>
|
||
<div id="launcherPlayersList" style="max-height:320px; overflow:auto;"></div>
|
||
<p style="opacity:.7; font-size:13px; margin-top:12px;">
|
||
Filled automatically: when a player signs in to the Campus launcher, it proves their Steam account
|
||
with a ticket from Steam and their SteamID is added here. With the requirement <strong>on</strong>,
|
||
a PC (Steam) login is let in only if its SteamID is here or on the Steam Allowlist above - so PC
|
||
players have to come through our launcher. Quest / Meta is never affected. Stored in
|
||
<code>Data/steam_launcher_players.json</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>
|
||
|
||
<!-- Appearance: the website's own theme (shared with the site through localStorage) -->
|
||
<div class="card" style="margin:0 0 16px; padding:18px 20px; max-width:560px;">
|
||
<h3 style="margin:0 0 4px;">Appearance</h3>
|
||
<p style="margin:0 0 14px;">The panel uses the Campus website's theme. Changing it here changes it on the website too, in this browser.</p>
|
||
<div class="cd-modes">
|
||
<button type="button" class="cd-mode" data-theme="light">Light</button>
|
||
<button type="button" class="cd-mode" data-theme="dark">Dark</button>
|
||
<button type="button" class="cd-mode" data-theme="autumn">Autumn</button>
|
||
</div>
|
||
</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">Creator</th>
|
||
<th scope="col">Created At</th>
|
||
<th scope="col">Members</th>
|
||
<th scope="col">Visibility</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 — 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 & 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="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
|
||
<h4 class="section-title">Import from a file on the server</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 — 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>
|
||
|
||
<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 — 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="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() {
|
||
|
||
}
|
||
|
||
// ---- Clubs (Dashboard -> View All Clubs) ----
|
||
function loadClubs() {
|
||
fetchAsync('/admin/api/clubs').then(function (clubs) {
|
||
var rows = (clubs || []).map(function (c) {
|
||
return '<tr><td>' + c.ClubId + '</td>'
|
||
+ '<td><img src="/img/' + escapeHtml(c.MainImageName || 'DefaultClubImage.png') + '?width=96&cropSquare=true" class="profile-img" alt=""></td>'
|
||
+ '<td>' + escapeHtml(c.Name) + '</td>'
|
||
+ '<td>' + (c.CreatorName ? '@' + escapeHtml(c.CreatorName) : '#' + c.CreatorAccountId) + '</td>'
|
||
+ '<td>' + formatDate(c.CreatedAt) + '</td>'
|
||
+ '<td>' + c.Members + '</td>'
|
||
+ '<td>' + (c.Visibility === 1 ? 'Public' : 'Private') + '</td></tr>';
|
||
}).join('');
|
||
document.getElementById('clubs_list').innerHTML = rows || '<tr><td colspan="7" style="color:var(--muted)">No clubs yet.</td></tr>';
|
||
});
|
||
}
|
||
|
||
// ---- Rooms tab: Delete ----
|
||
function deleteRoom(roomId, name) {
|
||
if (!confirm('Delete ^' + name + ' (room ' + roomId + ')? This cannot be undone.')) return;
|
||
var f = new FormData(); f.append('roomId', roomId);
|
||
fetch('/admin/api/delete_room', { method: 'POST', body: f, credentials: 'same-origin' })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (d) { showPopup(d.success ? 'Deleted' : 'Not deleted', d.message || ''); if (d.success) loadRooms(); })
|
||
.catch(function (e) { showPopup('Error', 'Delete failed: ' + e); });
|
||
}
|
||
|
||
// ---- Gifts tab: skins (Data/APIS/items/Equipments.json) ----
|
||
var _skinTimer = null;
|
||
function skinSearchLive() { clearTimeout(_skinTimer); _skinTimer = setTimeout(skinSearch, 250); }
|
||
function skinSearch() {
|
||
var q = (document.getElementById('skinSearchBox').value || '').trim();
|
||
var pid = (document.getElementById('giftPlayerId').value || '').trim();
|
||
var url = '/admin/api/gift/skins?q=' + encodeURIComponent(q) + (/^\d+$/.test(pid) ? '&playerId=' + pid : '');
|
||
fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (d) {
|
||
var sum = document.getElementById('skinSummary'), list = document.getElementById('skinResults');
|
||
if (!d.success) { sum.textContent = d.message || 'Search failed.'; list.innerHTML = ''; return; }
|
||
sum.textContent = d.matches + ' of ' + d.total + ' skins' + (d.matches > d.items.length ? ' (showing ' + d.items.length + ')' : '')
|
||
+ (/^\d+$/.test(pid) ? ' - ownership shown for player ' + pid : '');
|
||
list.innerHTML = d.items.map(function (it, i) {
|
||
var tags = '<span class="re-chip">' + escapeHtml(it.tool) + '</span>'
|
||
+ (it.sold ? '<span class="re-chip">in store</span>' : '<span class="re-chip">not sold</span>')
|
||
+ (it.owned === true ? '<span class="re-chip" style="color:var(--ok)">owned</span>' : '');
|
||
return '<div class="card" style="padding:10px 14px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">'
|
||
+ '<div style="flex:1; min-width:200px;"><b>' + escapeHtml(it.name) + '</b><div style="display:flex; gap:6px; flex-wrap:wrap; margin-top:4px;">' + tags + '</div></div>'
|
||
+ '<button class="btn btn-primary" data-prefab="' + escapeHtml(it.prefab) + '" data-guid="' + escapeHtml(it.guid) + '" data-name="' + escapeHtml(it.name) + '" onclick="skinGive(this)"'
|
||
+ (it.owned === true ? ' disabled title="Already owned"' : '') + '>Give</button></div>';
|
||
}).join('') || '<span style="color:var(--muted)">No skins match.</span>';
|
||
});
|
||
}
|
||
function skinGive(btn) {
|
||
var pid = (document.getElementById('giftPlayerId').value || '').trim();
|
||
if (!pid) { showPopup('Error', 'Enter a player id first (or -1 for everyone).'); return; }
|
||
var name = btn.getAttribute('data-name');
|
||
if (pid === '-1' && !confirm('Give ' + name + ' to EVERY player? (Owners are skipped.)')) return;
|
||
var f = new FormData();
|
||
f.append('playerId', pid);
|
||
f.append('prefab', btn.getAttribute('data-prefab'));
|
||
f.append('guid', btn.getAttribute('data-guid'));
|
||
f.append('mode', document.getElementById('skinMode').value);
|
||
f.append('message', document.getElementById('giftMessage').value || '');
|
||
btn.disabled = true;
|
||
fetch('/admin/api/gift/skin', { method: 'POST', body: f, credentials: 'same-origin' })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (d) { showPopup(d.success ? 'Gift sent' : 'Not sent', d.message || ''); if (d.success) skinSearch(); else btn.disabled = false; })
|
||
.catch(function (e) { btn.disabled = false; showPopup('Error', 'Gift failed: ' + e); });
|
||
}
|
||
|
||
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);
|
||
editProfileLockShow('bio', !!data.bioLocked);
|
||
editProfileLockShow('picture', !!data.pictureLocked);
|
||
editProfileLockShow('banner', !!data.bannerLocked);
|
||
editJuniorShow(!!data.isJunior);
|
||
edit2faShow(!!data.twoFactorEnabled);
|
||
|
||
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>'
|
||
+ ' | Discord: <b>' + discordLabel + '</b>'
|
||
+ (data.nameLocked ? '<br><b style="color:#e67e22;">Name locked</b> — this player cannot rename themselves.' : '')
|
||
+ (data.isJunior ? '<br><b style="color:#3498db;">Junior account</b>' : '');
|
||
})
|
||
.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> — player cannot change their names'
|
||
: 'Unlocked — 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)';
|
||
}
|
||
|
||
let editIsJunior = false;
|
||
function editJuniorShow(junior) {
|
||
editIsJunior = !!junior;
|
||
document.getElementById('editJuniorState').innerHTML = editIsJunior
|
||
? '<b style="color:#3498db;">JUNIOR</b> — under-13 restrictions on'
|
||
: 'Not junior';
|
||
const btn = document.getElementById('editJuniorBtn');
|
||
btn.textContent = editIsJunior ? 'Remove junior' : 'Make junior';
|
||
btn.style.background = editIsJunior ? '#2980b9' : 'var(--card-hover-bg)';
|
||
}
|
||
|
||
function toggle_junior() {
|
||
const id = editId();
|
||
if (!id) return;
|
||
const form = new URLSearchParams();
|
||
form.append('playerId', id);
|
||
form.append('junior', editIsJunior ? 'false' : 'true');
|
||
fetch('/admin/api/set_junior', {
|
||
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!', 'Junior change failed: ' + err));
|
||
}
|
||
|
||
// Bio / profile picture / banner locks (POST /admin/api/set_profile_lock).
|
||
const editProfileLocks = { bio: false, picture: false, banner: false };
|
||
const PROFILE_LOCK_NOUN = { bio: 'bio', picture: 'profile picture', banner: 'banner' };
|
||
function editProfileLockShow(what, locked) {
|
||
editProfileLocks[what] = !!locked;
|
||
document.getElementById('editLockState_' + what).innerHTML = locked
|
||
? '<b style="color:#e67e22;">LOCKED</b> — player cannot change their ' + PROFILE_LOCK_NOUN[what]
|
||
: 'Unlocked — player may change their ' + PROFILE_LOCK_NOUN[what];
|
||
const btn = document.getElementById('editLockBtn_' + what);
|
||
btn.textContent = (locked ? 'Unlock ' : 'Lock ') + (what === 'picture' ? 'picture' : what);
|
||
btn.style.background = locked ? '#c0392b' : 'var(--card-hover-bg)';
|
||
}
|
||
function toggle_profile_lock(what) {
|
||
const id = editId();
|
||
if (!id) return;
|
||
const form = new URLSearchParams();
|
||
form.append('playerId', id);
|
||
form.append('what', what);
|
||
form.append('locked', editProfileLocks[what] ? 'false' : 'true');
|
||
fetch('/admin/api/set_profile_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!', 'Lock failed: ' + err));
|
||
}
|
||
|
||
// 2FA (TwoFactorDB, shared by the website and the admin panel - the "lost my phone" reset).
|
||
function edit2faShow(enabled) {
|
||
document.getElementById('edit2faState').innerHTML = enabled
|
||
? '<b style="color:#3498db;">ON</b> — player needs a code to sign in'
|
||
: 'Off';
|
||
document.getElementById('edit2faBtn').style.display = enabled ? 'inline-block' : 'none';
|
||
}
|
||
function edit2faDisable() {
|
||
const id = editId();
|
||
if (!id) return;
|
||
if (!confirm('Turn off 2FA for this player? Only do this if they actually lost access to their authenticator app.')) return;
|
||
const form = new URLSearchParams();
|
||
form.append('playerId', id);
|
||
fetch('/admin/api/player/2fa/disable', {
|
||
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!', '2FA disable failed: ' + err));
|
||
}
|
||
|
||
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 => (
|
||
{ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[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, '"') + '" 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)+'" title="Click for the full photo" onclick="window.open(\''+esc(p.fullUrl || p.imageUrl)+'\')" style="width:100%; height:150px; object-fit:cover; border-radius:6px; background:rgba(0,0,0,.2); cursor:zoom-in;" 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)+' · '+esc(when)+' · '+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 imgImport() {
|
||
const msg = document.getElementById('imgImportMsg');
|
||
const f = document.getElementById('imgImportFile').files[0];
|
||
if (!f) { msg.textContent = 'Pick an image file first.'; return; }
|
||
const fd = new FormData();
|
||
fd.append('file', f);
|
||
fd.append('owner', document.getElementById('imgImportOwner').value.trim());
|
||
fd.append('description', document.getElementById('imgImportDesc').value);
|
||
fd.append('roomId', document.getElementById('imgImportRoom').value.trim());
|
||
fd.append('public', document.getElementById('imgImportPublic').checked ? 'true' : 'false');
|
||
msg.textContent = 'Uploading...';
|
||
fetch('/admin/api/images/import', { method: 'POST', credentials: 'same-origin', body: fd })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.success) { msg.textContent = d.message || 'Import failed.'; return; }
|
||
msg.innerHTML = 'Imported as image <b>' + esc(d.id) + '</b> (<a href="' + esc(d.url) + '" target="_blank">' + esc(d.imageName) + '</a>).';
|
||
document.getElementById('imgImportFile').value = '';
|
||
photosLoad();
|
||
})
|
||
.catch(err => { msg.textContent = 'Import 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 = [];
|
||
|
||
// ---- Limited Time Events (progression events) -------------------------------------
|
||
let _ev = []; // [{def, status, participants}]
|
||
let _evEdit = null; // working copy of the def being edited
|
||
let _evPick = null; // {step, row} the item picker fills
|
||
function evFmt(iso) { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }); }
|
||
function evLocalInput(iso) { // ISO (UTC) -> value for <input type=datetime-local> in the viewer's time
|
||
if (!iso) return ''; const d = new Date(iso); if (isNaN(d)) return '';
|
||
const z = n => String(n).padStart(2, '0');
|
||
return d.getFullYear() + '-' + z(d.getMonth() + 1) + '-' + z(d.getDate()) + 'T' + z(d.getHours()) + ':' + z(d.getMinutes());
|
||
}
|
||
function evFromInput(v) { if (!v) return null; const d = new Date(v); return isNaN(d) ? null : d.toISOString(); }
|
||
function evLoad() {
|
||
fetch('/admin/api/progression_events', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.success) { document.getElementById('evMsg').textContent = d.message || 'Failed.'; return; }
|
||
_ev = d.events;
|
||
document.getElementById('evRule').textContent = d.rule.xp + ' XP every ' + d.rule.minutes + ' minutes, up to ' + d.rule.cap + ' XP a day';
|
||
const colors = { 'off': '#7f8c8d', 'scheduled': '#2980b9', 'live': '#27ae60', 'live (developers only)': '#d68910', 'collecting only': '#8e44ad', 'ended': '#555' };
|
||
document.getElementById('evList').innerHTML = _ev.length === 0
|
||
? '<div style="opacity:.7;">No events yet.</div>'
|
||
: _ev.map((x, i) => {
|
||
const e = x.def;
|
||
return '<div style="display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; align-items:center; border:1px solid rgba(128,128,128,.3); border-radius:8px; padding:10px 12px;">'
|
||
+ '<div><span style="background:' + (colors[x.status] || '#555') + '; color:#fff; border-radius:9px; padding:2px 9px; font-size:12px; margin-right:8px;">' + esc(x.status) + '</span>'
|
||
+ '<b>' + esc(e.Name) + '</b> <span style="opacity:.65; font-size:13px;">' + esc(evFmt(e.StartTime)) + ' → ' + esc(evFmt(e.EndTime)) + ' · ' + e.Steps.length + ' steps · ' + x.participants + ' player(s)</span></div>'
|
||
+ '<div style="display:flex; gap:6px;">'
|
||
+ '<button onclick="evToggle(' + i + ');" style="background:' + (e.Enabled ? '#7f8c8d' : '#27ae60') + '; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">' + (e.Enabled ? 'Turn off' : 'Turn on') + '</button>'
|
||
+ '<button onclick="evEdit(' + i + ');" style="background:var(--card-hover-bg); color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Edit</button>'
|
||
+ '<button onclick="evDelete(' + i + ');" style="background:#c0392b; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Delete</button>'
|
||
+ '</div></div>';
|
||
}).join('');
|
||
const sel = document.getElementById('evToolEvent'), keep = sel.value;
|
||
sel.innerHTML = _ev.map(x => '<option value="' + esc(x.def.Id) + '">' + esc(x.def.Name) + '</option>').join('');
|
||
if (keep) sel.value = keep;
|
||
})
|
||
.catch(() => { document.getElementById('evMsg').textContent = 'Could not load the events.'; });
|
||
}
|
||
function evTokens(n) { return { Kind: 'tokens', Tokens: n }; }
|
||
function evNew(template) {
|
||
const start = new Date(); start.setMinutes(0, 0, 0);
|
||
const end = new Date(start.getTime() + 14 * 86400000);
|
||
const steps = [];
|
||
if (template) {
|
||
const xp = [100, 110, 120, 120, 130, 140, 150, 160, 180, 200];
|
||
const free = [25, 25, 50, 50, 75, 75, 100, 100, 150, 250];
|
||
const plus = [50, 75, 100, 100, 150, 150, 200, 250, 300, 500];
|
||
for (let i = 0; i < xp.length; i++) steps.push({ Xp: xp[i], Free: evTokens(free[i]), Premium: evTokens(plus[i]) });
|
||
} else steps.push({ Xp: 100, Free: evTokens(50), Premium: evTokens(100) });
|
||
_evEdit = { Id: '', Name: template ? 'Summer Party!' : 'New Event', StartTime: start.toISOString(), EndTime: end.toISOString(),
|
||
CollectionEndTime: null, Enabled: false, DevelopersOnly: true, PremiumForEveryone: true,
|
||
ChallengesIconImageName: '', EventInfoImageName: '', Steps: steps };
|
||
evRender();
|
||
}
|
||
function evEdit(i) { _evEdit = JSON.parse(JSON.stringify(_ev[i].def)); evRender(); }
|
||
function evRewardCell(si, row) {
|
||
const r = _evEdit.Steps[si][row] || { Kind: 'none' };
|
||
const kind = r.Kind || 'none';
|
||
let inner = '<select onchange="evSetKind(' + si + ',\'' + row + '\',this.value);" style="padding:7px; border:1px solid #ccc; border-radius:5px;">'
|
||
+ ['tokens', 'item', 'none'].map(k => '<option value="' + k + '"' + (k === kind ? ' selected' : '') + '>' + (k === 'tokens' ? 'Tokens' : k === 'item' ? 'Store item' : 'Nothing') + '</option>').join('') + '</select> ';
|
||
if (kind === 'tokens') inner += '<input type="number" min="1" max="100000" value="' + (r.Tokens || 50) + '" onchange="_evEdit.Steps[' + si + '].' + row + '.Tokens=+this.value;" style="padding:7px; border:1px solid #ccc; border-radius:5px; width:90px;" />';
|
||
if (kind === 'item') inner += (r.ImageName ? '<img src="/img/' + esc(r.ImageName) + '?width=64" style="width:28px; height:28px; vertical-align:middle; border-radius:4px; margin-right:4px;" onerror="this.style.display=\'none\'" />' : '')
|
||
+ '<span style="font-size:13px;">' + esc(r.FriendlyName || '(none picked)') + '</span> '
|
||
+ '<button onclick="evPickOpen(' + si + ',\'' + row + '\');" style="background:#555; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Pick</button>';
|
||
return inner;
|
||
}
|
||
function evSetKind(si, row, kind) {
|
||
const cur = _evEdit.Steps[si][row] || {};
|
||
_evEdit.Steps[si][row] = kind === 'none' ? { Kind: 'none' } : kind === 'tokens' ? { Id: cur.Id, Kind: 'tokens', Tokens: cur.Tokens || 50 } : { Id: cur.Id, Kind: 'item' };
|
||
evRender();
|
||
}
|
||
function evRender() {
|
||
const e = _evEdit, box = document.getElementById('evEditor');
|
||
if (!e) { box.style.display = 'none'; return; }
|
||
box.style.display = 'block';
|
||
const lab = 'display:flex; flex-direction:column; gap:4px; font-size:13px;';
|
||
box.innerHTML = '<div style="display:flex; justify-content:space-between; align-items:center;"><b>' + (e.Id ? 'Edit event' : 'New event') + '</b>'
|
||
+ '<button onclick="_evEdit=null; evRender();" style="background:#7f8c8d; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Close</button></div>'
|
||
+ '<div style="display:flex; gap:12px; flex-wrap:wrap; margin-top:10px;">'
|
||
+ '<label style="' + lab + '">Name<input type="text" maxlength="60" value="' + esc(e.Name) + '" onchange="_evEdit.Name=this.value;" style="padding:7px; border:1px solid #ccc; border-radius:5px; min-width:220px;" /></label>'
|
||
+ '<label style="' + lab + '">Starts<input type="datetime-local" value="' + evLocalInput(e.StartTime) + '" onchange="_evEdit.StartTime=evFromInput(this.value);" style="padding:7px; border:1px solid #ccc; border-radius:5px;" /></label>'
|
||
+ '<label style="' + lab + '">Ends (XP stops)<input type="datetime-local" value="' + evLocalInput(e.EndTime) + '" onchange="_evEdit.EndTime=evFromInput(this.value);" style="padding:7px; border:1px solid #ccc; border-radius:5px;" /></label>'
|
||
+ '<label style="' + lab + '">Collect until (optional)<input type="datetime-local" value="' + evLocalInput(e.CollectionEndTime) + '" onchange="_evEdit.CollectionEndTime=evFromInput(this.value);" style="padding:7px; border:1px solid #ccc; border-radius:5px;" /></label>'
|
||
+ '</div>'
|
||
+ '<div style="display:flex; gap:12px; flex-wrap:wrap; margin-top:10px;">'
|
||
+ '<label style="' + lab + '">Watch icon image (optional)<input type="text" value="' + esc(e.ChallengesIconImageName || '') + '" onchange="_evEdit.ChallengesIconImageName=this.value;" placeholder="image name" style="padding:7px; border:1px solid #ccc; border-radius:5px;" /></label>'
|
||
+ '<label style="' + lab + '">Info image (optional)<input type="text" value="' + esc(e.EventInfoImageName || '') + '" onchange="_evEdit.EventInfoImageName=this.value;" placeholder="image name" style="padding:7px; border:1px solid #ccc; border-radius:5px;" /></label>'
|
||
+ '</div>'
|
||
+ '<div style="display:flex; gap:18px; flex-wrap:wrap; margin-top:12px;">'
|
||
+ '<label><input type="checkbox"' + (e.Enabled ? ' checked' : '') + ' onchange="_evEdit.Enabled=this.checked;" /> On</label>'
|
||
+ '<label><input type="checkbox"' + (e.DevelopersOnly ? ' checked' : '') + ' onchange="_evEdit.DevelopersOnly=this.checked;" /> Developers only (testing)</label>'
|
||
+ '<label><input type="checkbox"' + (e.PremiumForEveryone ? ' checked' : '') + ' onchange="_evEdit.PremiumForEveryone=this.checked;" /> RR+ row collectable by everyone</label>'
|
||
+ '</div>'
|
||
+ '<table style="width:100%; border-collapse:collapse; margin-top:12px; font-size:14px;"><tr style="text-align:left; opacity:.7;"><th style="padding:4px;">#</th><th style="padding:4px;">XP to reach</th><th style="padding:4px;">Free reward</th><th style="padding:4px;">RR+ reward</th><th></th></tr>'
|
||
+ e.Steps.map((s, si) => '<tr style="border-top:1px solid rgba(128,128,128,.2);">'
|
||
+ '<td style="padding:6px 4px;">' + (si + 1) + '</td>'
|
||
+ '<td style="padding:6px 4px;"><input type="number" min="1" max="100000" value="' + s.Xp + '" onchange="_evEdit.Steps[' + si + '].Xp=+this.value;" style="padding:7px; border:1px solid #ccc; border-radius:5px; width:80px;" /></td>'
|
||
+ '<td style="padding:6px 4px;">' + evRewardCell(si, 'Free') + '</td>'
|
||
+ '<td style="padding:6px 4px;">' + evRewardCell(si, 'Premium') + '</td>'
|
||
+ '<td style="padding:6px 4px;"><button onclick="_evEdit.Steps.splice(' + si + ',1); evRender();" style="background:#7f8c8d; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Remove</button></td></tr>').join('')
|
||
+ '</table>'
|
||
+ '<div style="margin-top:8px;"><button onclick="const l=_evEdit.Steps[_evEdit.Steps.length-1]; _evEdit.Steps.push({Xp:l?l.Xp:100, Free:evTokens(50), Premium:evTokens(100)}); evRender();" style="background:#555; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">+ Add step</button></div>'
|
||
+ '<div id="evPicker" style="display:none; margin-top:12px; border:1px dashed rgba(128,128,128,.5); border-radius:8px; padding:10px;">'
|
||
+ '<div style="display:flex; gap:8px;"><input type="text" id="evPickQ" placeholder="Search store items" onkeydown="if(event.key===\'Enter\')evPickSearch();" style="padding:7px; border:1px solid #ccc; border-radius:5px; min-width:240px;" />'
|
||
+ '<button onclick="evPickSearch();" style="background:var(--card-hover-bg); color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Search</button>'
|
||
+ '<button onclick="document.getElementById(\'evPicker\').style.display=\'none\';" style="background:#7f8c8d; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Cancel</button></div>'
|
||
+ '<div id="evPickOut" style="display:flex; flex-wrap:wrap; gap:8px; margin-top:10px;"></div></div>'
|
||
+ '<div style="margin-top:14px;"><button onclick="evSave();" style="background:var(--card-hover-bg); color:white; padding:9px 16px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Save event</button></div>';
|
||
}
|
||
function evPickOpen(si, row) { _evPick = { si, row }; document.getElementById('evPicker').style.display = 'block'; document.getElementById('evPickQ').focus(); }
|
||
function evPickSearch() {
|
||
const q = document.getElementById('evPickQ').value.trim();
|
||
fetch('/admin/api/progression_events/items?q=' + encodeURIComponent(q), { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
window._evPickItems = d.items || [];
|
||
document.getElementById('evPickOut').innerHTML = _evPickItems.length === 0 ? '<span style="opacity:.7;">No items.</span>'
|
||
: _evPickItems.map((it, i) => '<div onclick="evPickTake(' + i + ');" title="' + esc(it.FriendlyName) + '" style="cursor:pointer; width:92px; text-align:center; font-size:12px; border:1px solid rgba(128,128,128,.3); border-radius:6px; padding:6px;">'
|
||
+ '<img src="/img/' + esc(it.ThumbnailImageName) + '?width=64" style="width:56px; height:56px; object-fit:contain;" onerror="this.style.visibility=\'hidden\'" /><div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + esc(it.FriendlyName) + '</div></div>').join('');
|
||
});
|
||
}
|
||
function evPickTake(i) {
|
||
const it = _evPickItems[i]; if (!_evPick) return;
|
||
const cur = _evEdit.Steps[_evPick.si][_evPick.row] || {};
|
||
_evEdit.Steps[_evPick.si][_evPick.row] = { Id: cur.Id, Kind: 'item', GiftDropId: it.GiftDropId, FriendlyName: it.FriendlyName, ImageName: it.ThumbnailImageName };
|
||
_evPick = null; evRender();
|
||
}
|
||
function evSave() {
|
||
fetch('/admin/api/progression_events/save', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(_evEdit) })
|
||
.then(r => r.json())
|
||
.then(d => { document.getElementById('evMsg').textContent = d.message || (d.success ? 'Saved.' : 'Failed.'); if (d.success) { _evEdit = null; evRender(); evLoad(); } })
|
||
.catch(() => { document.getElementById('evMsg').textContent = 'Could not save.'; });
|
||
}
|
||
function evToggle(i) {
|
||
const def = JSON.parse(JSON.stringify(_ev[i].def)); def.Enabled = !def.Enabled;
|
||
if (def.Enabled && !def.DevelopersOnly && !confirm('Turn "' + def.Name + '" on for EVERYONE?')) return;
|
||
_evEdit = def; evSave();
|
||
}
|
||
function evDelete(i) {
|
||
if (!confirm('Delete "' + _ev[i].def.Name + '"? Player progress on it is kept but no longer shown.')) return;
|
||
const f = new FormData(); f.append('id', _ev[i].def.Id);
|
||
fetch('/admin/api/progression_events/delete', { method: 'POST', credentials: 'same-origin', body: f })
|
||
.then(r => r.json()).then(d => { document.getElementById('evMsg').textContent = d.message || ''; evLoad(); });
|
||
}
|
||
function evToolPost(extra) {
|
||
const f = new FormData();
|
||
f.append('id', document.getElementById('evToolEvent').value);
|
||
f.append('player', document.getElementById('evToolPlayer').value.trim());
|
||
for (const k in extra) f.append(k, extra[k]);
|
||
return fetch('/admin/api/progression_events/player', { method: 'POST', credentials: 'same-origin', body: f })
|
||
.then(r => r.json()).then(d => { document.getElementById('evToolOut').textContent = d.message || (d.success ? 'Done.' : 'Failed.'); });
|
||
}
|
||
function evToolLook() {
|
||
const id = document.getElementById('evToolEvent').value, p = document.getElementById('evToolPlayer').value.trim();
|
||
fetch('/admin/api/progression_events/player?id=' + encodeURIComponent(id) + '&player=' + encodeURIComponent(p), { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => { document.getElementById('evToolOut').textContent = d.success
|
||
? '@' + d.name + ': ' + d.xp + ' XP (' + d.xpToday + ' today, ' + d.minutesToday + ' min in public rooms today), ' + d.collected + ' reward(s) collected'
|
||
: (d.message || 'Failed.'); });
|
||
}
|
||
function evToolXp() { evToolPost({ xp: document.getElementById('evToolXp').value }); }
|
||
function evToolReset() { if (confirm('Reset this player\'s progress on the event?')) evToolPost({ reset: '1' }); }
|
||
|
||
// ---- Stores -----------------------------------------------------------------------
|
||
// Store editor (/admin/api/stores*): overlay edits in Data/APIS/store-edits.json.
|
||
let _stStores = [], _stCurrencies = [], _stData = null, _stPick = null, _stSearchTimer = null;
|
||
const stBtn = 'color:white; border:none; padding:6px 12px; border-radius:5px; cursor:pointer;';
|
||
|
||
function stCurName(id) {
|
||
const c = _stCurrencies.find(x => x.id === id);
|
||
return c ? c.name : ('currency ' + id);
|
||
}
|
||
function stCurOptions(sel) {
|
||
return _stCurrencies.map(c => '<option value="' + c.id + '"' + (c.id === sel ? ' selected' : '') + '>' + esc(c.name) + '</option>').join('');
|
||
}
|
||
function stStoreId() { return parseInt(document.getElementById('stSelect').value, 10); }
|
||
|
||
function stInit() {
|
||
fetch('/admin/api/stores', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.success) throw new Error('failed');
|
||
_stStores = d.stores || []; _stCurrencies = d.currencies || [];
|
||
const sel = document.getElementById('stSelect');
|
||
const keep = sel.value;
|
||
sel.innerHTML = _stStores.map(s => {
|
||
const edits = [];
|
||
if (s.added) edits.push('+' + s.added + ' added');
|
||
if (s.edited) edits.push(s.edited + ' edited');
|
||
if (s.removed) edits.push(s.removed + ' removed');
|
||
return '<option value="' + s.id + '">' + esc(s.name) + ' (' + s.id + ')' + (s.hasFile ? '' : ' - empty') + (edits.length ? ' - ' + edits.join(', ') : '') + '</option>';
|
||
}).join('');
|
||
sel.value = keep && _stStores.some(s => String(s.id) === keep) ? keep : '3';
|
||
stLoad();
|
||
})
|
||
.catch(() => { document.getElementById('stMsg').textContent = 'Could not load the stores.'; });
|
||
}
|
||
|
||
function stLoad() {
|
||
const id = stStoreId();
|
||
fetch('/admin/api/stores/' + id, { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.success) throw new Error('failed');
|
||
_stData = d;
|
||
const live = d.items.filter(i => i.state !== 'removed').length;
|
||
document.getElementById('stSummary').textContent = live + ' item(s) for sale, ' + d.sets.length + ' set(s). Main currency: ' + stCurName(d.currency) + '.';
|
||
document.getElementById('stAddCurrency').innerHTML = stCurOptions(d.currency);
|
||
document.getElementById('stSetsList').innerHTML = d.sets.map(s => '<option value="' + s.setId + '">' + esc(s.name || ('Set ' + s.setId)) + ' (' + s.count + ')</option>').join('');
|
||
document.getElementById('stAddSetId').placeholder = 'none (next free: ' + d.nextSetId + ')';
|
||
stRender();
|
||
})
|
||
.catch(() => { document.getElementById('stMsg').textContent = 'Could not load that store.'; });
|
||
}
|
||
|
||
function stSetIdTyped(idField, nameField) {
|
||
if (!_stData) return;
|
||
const v = parseInt(document.getElementById(idField).value, 10);
|
||
const s = _stData.sets.find(x => x.setId === v);
|
||
if (s) document.getElementById(nameField).value = s.name;
|
||
}
|
||
|
||
function stStateBadge(state) {
|
||
const col = { added: '#27ae60', edited: '#2980b9', removed: '#c0392b' }[state];
|
||
return col ? ' <span style="background:' + col + '; color:white; font-size:11px; padding:1px 6px; border-radius:8px;">' + state + '</span>' : '';
|
||
}
|
||
|
||
function stRowHtml(it) {
|
||
const thumb = it.thumb ? '<img src="/img/' + encodeURIComponent(it.thumb) + '" loading="lazy" style="width:44px; height:44px; object-fit:contain; border-radius:6px; background:#0002;" onerror="this.style.visibility=\'hidden\'" />'
|
||
: '<div style="width:44px; height:44px;"></div>';
|
||
const removed = it.state === 'removed';
|
||
const act = removed
|
||
? '<button onclick="stRestore(' + it.pid + ');" style="background:#27ae60; ' + stBtn + '">Restore</button>'
|
||
: '<button onclick="stSaveRow(' + it.pid + ');" style="background:var(--card-hover-bg); ' + stBtn + '">Save</button>'
|
||
+ (it.state === 'edited' ? ' <button onclick="stRestore(' + it.pid + ');" title="Undo your changes to this item" style="background:#7f8c8d; ' + stBtn + '">Undo</button>' : '')
|
||
+ ' <button onclick="stRemove(' + it.pid + ',' + (it.state === 'added') + ');" style="background:#c0392b; ' + stBtn + '">' + (it.state === 'added' ? 'Delete' : 'Remove') + '</button>';
|
||
return '<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:8px; border-radius:8px; background:var(--card-bg, #0001);' + (removed ? ' opacity:.5;' : '') + '">'
|
||
+ thumb
|
||
+ '<div style="min-width:200px; flex:1;"><b>' + esc(it.name || '(unnamed)') + '</b>' + stStateBadge(it.state)
|
||
+ '<div style="opacity:.65; font-size:12px;">item ' + it.pid + ' · ' + esc(it.kind) + ' · rarity ' + it.rarity + '</div></div>'
|
||
+ '<label style="display:flex; gap:4px; align-items:center;">Price <input type="number" id="stP' + it.pid + '" value="' + it.price + '" min="0" style="width:80px; padding:5px; border:1px solid #ccc; border-radius:5px;"' + (removed ? ' disabled' : '') + ' /></label>'
|
||
+ '<select id="stC' + it.pid + '" style="padding:5px; border:1px solid #ccc; border-radius:5px;"' + (removed ? ' disabled' : '') + '>' + stCurOptions(it.currency) + '</select>'
|
||
+ '<label style="display:flex; gap:4px; align-items:center;">Set <input type="number" id="stS' + it.pid + '" value="' + (it.setId == null ? '' : it.setId) + '" min="1" list="stSetsList" placeholder="none" style="width:70px; padding:5px; border:1px solid #ccc; border-radius:5px;"' + (removed ? ' disabled' : '') + ' /></label>'
|
||
+ '<label style="display:flex; gap:4px; align-items:center;"><input type="checkbox" id="stF' + it.pid + '"' + (it.featured ? ' checked' : '') + (removed ? ' disabled' : '') + ' /> Featured</label>'
|
||
+ act
|
||
+ '</div>';
|
||
}
|
||
|
||
function stRender() {
|
||
const box = document.getElementById('stItems');
|
||
if (!_stData) { box.innerHTML = ''; return; }
|
||
const q = document.getElementById('stFilter').value.trim().toLowerCase();
|
||
const showRemoved = document.getElementById('stShowRemoved').checked;
|
||
let items = _stData.items.filter(i => showRemoved || i.state !== 'removed');
|
||
if (q) items = items.filter(i => (i.name || '').toLowerCase().includes(q) || (i.setName || '').toLowerCase().includes(q)
|
||
|| String(i.pid) === q || (i.setId != null && String(i.setId) === q));
|
||
// Newest admin additions first, then the rest in store order.
|
||
items.sort((a, b) => (b.state === 'added') - (a.state === 'added'));
|
||
const cap = 300;
|
||
const shown = items.slice(0, cap);
|
||
let html = items.length > cap ? '<div style="opacity:.75;">Showing ' + cap + ' of ' + items.length + ' items - use the filter to find the rest.</div>' : '';
|
||
if (!items.length) { box.innerHTML = '<div style="opacity:.7;">No items' + (q ? ' match that filter' : ' in this store yet - add one above') + '.</div>'; return; }
|
||
if (document.getElementById('stGroup').checked) {
|
||
const groups = new Map();
|
||
for (const it of shown) {
|
||
const k = it.setId == null ? 'none' : it.setId;
|
||
if (!groups.has(k)) groups.set(k, []);
|
||
groups.get(k).push(it);
|
||
}
|
||
const keys = [...groups.keys()].sort((a, b) => a === 'none' ? 1 : b === 'none' ? -1 : a - b);
|
||
for (const k of keys) {
|
||
const list = groups.get(k);
|
||
if (k === 'none') {
|
||
html += '<div style="margin-top:6px;"><h4 style="margin:6px 0;">Not in a set (' + list.length + ')</h4>';
|
||
} else {
|
||
const set = _stData.sets.find(s => s.setId === k);
|
||
html += '<div style="margin-top:6px; border-left:4px solid var(--card-hover-bg); padding-left:10px;">'
|
||
+ '<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:6px 0;"><h4 style="margin:0;">Set #' + k + '</h4>'
|
||
+ '<input type="text" id="stSN' + k + '" value="' + esc(set ? set.name : '') + '" placeholder="set name" style="padding:5px; border:1px solid #ccc; border-radius:5px; width:220px;" />'
|
||
+ '<button onclick="stRenameSet(' + k + ');" style="background:#555; ' + stBtn + '">Rename set</button>'
|
||
+ '<span style="opacity:.65; font-size:13px;">' + list.length + ' item(s)</span></div>';
|
||
}
|
||
html += '<div style="display:flex; flex-direction:column; gap:6px;">' + list.map(stRowHtml).join('') + '</div></div>';
|
||
}
|
||
} else {
|
||
html += shown.map(stRowHtml).join('');
|
||
}
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
function stPost(path, body, msgEl) {
|
||
const el = document.getElementById(msgEl || 'stMsg');
|
||
el.textContent = 'Saving...';
|
||
return fetch('/admin/api/stores/' + stStoreId() + path, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||
.then(({ ok, d }) => { el.textContent = d.message || (ok ? 'Done.' : 'Failed.'); return { ok, d }; })
|
||
.catch(() => { el.textContent = 'Request failed.'; return { ok: false, d: {} }; });
|
||
}
|
||
|
||
function stSaveRow(pid) {
|
||
const it = _stData.items.find(i => i.pid === pid);
|
||
const setRaw = document.getElementById('stS' + pid).value.trim();
|
||
const setId = setRaw === '' ? null : parseInt(setRaw, 10);
|
||
const body = {
|
||
pid,
|
||
price: parseInt(document.getElementById('stP' + pid).value, 10) || 0,
|
||
currency: parseInt(document.getElementById('stC' + pid).value, 10),
|
||
featured: document.getElementById('stF' + pid).checked
|
||
};
|
||
if (setId !== (it ? it.setId : null)) {
|
||
body.setId = setId;
|
||
const set = _stData.sets.find(s => s.setId === setId);
|
||
if (setId != null) body.setName = set ? set.name : (prompt('Name for the new set #' + setId + ':', '') || '');
|
||
}
|
||
stPost('/update', body).then(({ ok }) => { if (ok) stInit(); });
|
||
}
|
||
|
||
function stRemove(pid, added) {
|
||
if (!confirm(added ? 'Delete this added item from the store?' : 'Take this item off sale? (Restore puts it back.)')) return;
|
||
stPost('/remove', { pid }).then(({ ok }) => { if (ok) stInit(); });
|
||
}
|
||
|
||
function stRestore(pid) {
|
||
stPost('/restore', { pid }).then(({ ok }) => { if (ok) stInit(); });
|
||
}
|
||
|
||
function stRenameSet(setId) {
|
||
const name = document.getElementById('stSN' + setId).value.trim();
|
||
if (!name) { alert('Type a name for the set first.'); return; }
|
||
stPost('/set', { setId, setName: name }).then(({ ok }) => { if (ok) stLoad(); });
|
||
}
|
||
|
||
function stSearchSoon() {
|
||
clearTimeout(_stSearchTimer);
|
||
_stSearchTimer = setTimeout(stSearch, 300);
|
||
}
|
||
|
||
function stSearch() {
|
||
const q = document.getElementById('stSearch').value.trim();
|
||
const box = document.getElementById('stResults');
|
||
if (!q) { box.innerHTML = ''; return; }
|
||
fetch('/admin/api/gift/search?q=' + encodeURIComponent(q), { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
const items = (d && d.items) || [];
|
||
if (!items.length) { box.innerHTML = '<div style="opacity:.7;">Nothing found.</div>'; return; }
|
||
window._stResults = items;
|
||
box.innerHTML = items.map((it, i) =>
|
||
'<div style="display:flex; gap:10px; align-items:center; justify-content:space-between; padding:6px 8px; border-radius:6px; background:var(--card-bg, #0001);">'
|
||
+ '<div style="min-width:0;"><b>' + esc(it.name) + '</b> <span style="opacity:.65; font-size:12px;">'
|
||
+ (it.source === 'store' ? ('sold in ' + esc(stStoreNameOf(it.storefrontType)) + ' as item ' + it.purchasableItemId) : 'avatar catalog') + ' · rarity ' + it.rarity + '</span></div>'
|
||
+ '<button onclick="stPickResult(' + i + ');" style="background:var(--card-hover-bg); ' + stBtn + ' flex:0 0 auto;">Pick</button></div>'
|
||
).join('');
|
||
})
|
||
.catch(() => { box.innerHTML = '<div style="opacity:.7;">Search failed.</div>'; });
|
||
}
|
||
|
||
function stStoreNameOf(id) {
|
||
const s = _stStores.find(x => x.id === id);
|
||
return s ? s.name : ('store ' + id);
|
||
}
|
||
|
||
function stPickResult(i) {
|
||
const it = (window._stResults || [])[i];
|
||
if (!it) return;
|
||
_stPick = it;
|
||
document.getElementById('stPicked').innerHTML = 'Picked: <b>' + esc(it.name) + '</b>'
|
||
+ (it.source === 'store' ? ' (copied from ' + esc(stStoreNameOf(it.storefrontType)) + ' item ' + it.purchasableItemId + ')' : ' (avatar catalog)');
|
||
}
|
||
|
||
function stAdd() {
|
||
if (!_stPick) { document.getElementById('stAddMsg').textContent = 'Search and pick an item first.'; return; }
|
||
const setRaw = document.getElementById('stAddSetId').value.trim();
|
||
const body = {
|
||
price: parseInt(document.getElementById('stAddPrice').value, 10) || 0,
|
||
currency: parseInt(document.getElementById('stAddCurrency').value, 10),
|
||
featured: document.getElementById('stAddFeatured').checked,
|
||
setId: setRaw === '' ? null : parseInt(setRaw, 10),
|
||
setName: document.getElementById('stAddSetName').value.trim()
|
||
};
|
||
if (_stPick.source === 'store') { body.sourceStore = _stPick.storefrontType; body.sourcePid = _stPick.purchasableItemId; }
|
||
else body.avatarItemDesc = _stPick.avatarItemDesc;
|
||
stPost('/add', body, 'stAddMsg').then(({ ok, d }) => {
|
||
if (!ok && d && d.duplicate) {
|
||
if (confirm((d.message || 'Already sold here.') + '\n\nAdd it anyway?')) {
|
||
body.allowDuplicate = true;
|
||
stPost('/add', body, 'stAddMsg').then(r => { if (r.ok) stInit(); });
|
||
}
|
||
return;
|
||
}
|
||
if (ok) stInit();
|
||
});
|
||
}
|
||
|
||
// ---- Challenges -------------------------------------------------------------------
|
||
// Daily challenge set + reward (Data/APIS/daily-objectives.json via /admin/api/challenges).
|
||
let _chal = null; // last GET
|
||
let _chalSets = []; // working copy: [[{type,score}], ...]
|
||
// ───── Weekly challenges (website /challenges): this/next week's reward picker ─────
|
||
let _wk = null, _wkView = 'current', _wkItems = [], _wkSeq = 0;
|
||
function wkLoad() {
|
||
fetch('/admin/api/weekly', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => { if (!d.success) { document.getElementById('wkMsg').textContent = d.message || 'Failed.'; return; } _wk = d; document.getElementById('wkNeed').textContent = d.required; wkRender(); })
|
||
.catch(() => { document.getElementById('wkMsg').textContent = 'Could not load the weekly challenges.'; });
|
||
}
|
||
function wkShow(which) {
|
||
_wkView = which;
|
||
document.getElementById('wkTabCur').style.background = which === 'current' ? 'var(--card-hover-bg)' : '#555';
|
||
document.getElementById('wkTabNext').style.background = which === 'next' ? 'var(--card-hover-bg)' : '#555';
|
||
wkRender();
|
||
}
|
||
function wkRender() {
|
||
if (!_wk) return;
|
||
const w = _wk[_wkView], r = w.reward;
|
||
const day = iso => new Date(iso).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
const rows = w.challenges.map(c => '<li style="margin:4px 0;">' + esc(c.text)
|
||
+ (_wkView === 'current' ? ' <span style="opacity:.6; font-size:12px;">(' + c.done + ' player' + (c.done === 1 ? '' : 's') + ' done)</span>' : '') + '</li>').join('');
|
||
const img = r && r.image ? '<img src="' + esc(r.image) + '?width=128" style="width:96px; height:96px; object-fit:contain; border-radius:8px; background:rgba(128,128,128,.12);" onerror="this.style.visibility=\'hidden\'" />' : '';
|
||
document.getElementById('wkWeek').innerHTML =
|
||
'<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:flex-start;">'
|
||
+ '<div style="flex:1; min-width:260px;"><b>' + day(w.startsAt) + ' → ' + day(w.endsAt) + '</b>'
|
||
+ '<ol style="margin:8px 0 0 18px; padding:0;">' + rows + '</ol>'
|
||
+ (_wkView === 'current' ? '<div style="opacity:.7; font-size:13px; margin-top:8px;">' + w.started + ' player(s) have progress, ' + w.rewarded + ' earned the reward.</div>' : '')
|
||
+ '</div>'
|
||
+ '<div style="width:240px; text-align:center; border:1px solid rgba(128,128,128,.3); border-radius:8px; padding:12px;">'
|
||
+ '<div style="font-size:12px; opacity:.7; text-transform:uppercase;">Reward' + (w.chosen ? ' (picked)' : ' (random skin)') + '</div>'
|
||
+ '<div style="margin:8px 0;">' + img + '</div>'
|
||
+ '<div style="font-weight:bold;">' + esc(r ? r.name : 'None') + '</div>'
|
||
+ '<div style="opacity:.7; font-size:12px;">' + (r ? (r.kind === 'skin' ? 'Skin' : 'Clothing') : '') + '</div>'
|
||
+ '<div style="display:flex; gap:6px; justify-content:center; margin-top:10px; flex-wrap:wrap;">'
|
||
+ '<button onclick="wkPickOpen();" style="background:var(--card-hover-bg); color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Pick reward</button>'
|
||
+ (w.chosen ? '<button onclick="wkSet(0);" style="background:#7f8c8d; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Use random</button>' : '')
|
||
+ '</div></div></div>';
|
||
}
|
||
function wkPickOpen() { document.getElementById('wkPicker').style.display = 'block'; document.getElementById('wkQ').focus(); if (_wkItems.length === 0) wkSearch(); }
|
||
function wkSearch() {
|
||
const q = document.getElementById('wkQ').value.trim(), kind = document.getElementById('wkKind').value;
|
||
const seq = ++_wkSeq; // only the latest search may fill the list
|
||
fetch('/admin/api/weekly/items?q=' + encodeURIComponent(q) + '&kind=' + encodeURIComponent(kind), { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (seq !== _wkSeq) return;
|
||
_wkItems = d.items || [];
|
||
document.getElementById('wkPickOut').innerHTML = _wkItems.length === 0 ? '<span style="opacity:.7;">No items.</span>'
|
||
: _wkItems.map((it, i) => '<div onclick="wkSet(' + it.giftDropId + ');" title="' + esc(it.name) + '" style="cursor:pointer; width:104px; text-align:center; font-size:12px; border:1px solid rgba(128,128,128,.3); border-radius:6px; padding:6px;">'
|
||
+ '<img src="' + esc(it.image) + '?width=64" style="width:56px; height:56px; object-fit:contain;" onerror="this.style.visibility=\'hidden\'" />'
|
||
+ '<div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + esc(it.name) + '</div>'
|
||
+ '<div style="opacity:.6;">' + (it.kind === 'skin' ? 'Skin' : 'Clothing') + '</div></div>').join('');
|
||
});
|
||
}
|
||
function wkSet(id) {
|
||
if (!_wk) return;
|
||
const f = new FormData();
|
||
f.append('map', _wk[_wkView].map); f.append('giftDropId', id);
|
||
fetch('/admin/api/weekly/reward', { method: 'POST', body: f, credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => { document.getElementById('wkMsg').textContent = d.message || ''; if (d.success) { document.getElementById('wkPicker').style.display = 'none'; wkLoad(); } })
|
||
.catch(() => { document.getElementById('wkMsg').textContent = 'That failed.'; });
|
||
}
|
||
function wkPlayerLoad() {
|
||
const who = document.getElementById('wkPlayer').value.trim(); if (!who) return;
|
||
fetch('/admin/api/weekly/player?player=' + encodeURIComponent(who), { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
const out = document.getElementById('wkPlayerOut');
|
||
if (!d.success) { out.textContent = d.message || 'Failed.'; return; }
|
||
out.innerHTML = '<b>@' + esc(d.name) + '</b>: ' + d.challenges.map(c => esc(c.text) + ' <b>' + c.progress + '/' + c.target + '</b>').join(' · ')
|
||
+ (d.rewardSent ? '<br>Reward sent ' + new Date(d.rewardSent).toLocaleString() + ' (' + esc(d.rewardName) + ')' : '');
|
||
});
|
||
}
|
||
function chalLoad() {
|
||
fetch('/admin/api/challenges', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.success) { document.getElementById('chalMsg').textContent = d.message || 'Failed.'; return; }
|
||
_chal = d;
|
||
_chalSets = d.sets.map(s => s.objectives.map(o => ({ type: o.type, score: o.score })));
|
||
if (d.sameEveryDay) _chalSets = [_chalSets[0]];
|
||
document.getElementById('chalReward').checked = d.rewardEnabled;
|
||
document.getElementById('chalXp').value = d.xpPerObjective;
|
||
document.getElementById('chalPerDay').checked = !d.sameEveryDay;
|
||
document.getElementById('chalSource').textContent = d.source === 'file' ? 'Custom set' : 'Built-in defaults';
|
||
document.getElementById('chalMsg').textContent = '';
|
||
const t = d.today, resets = new Date(d.resetsAtUtc);
|
||
const tile = (n, label) => '<div style="background:var(--table-bg); border-radius:10px; padding:12px 18px; min-width:150px;"><div style="font-size:26px; font-weight:bold;">' + n + '</div><div style="opacity:.7; font-size:13px;">' + label + '</div></div>';
|
||
document.getElementById('chalToday').innerHTML =
|
||
tile(t.finished, 'finished all today') + tile(t.started, 'completed at least one') + tile(t.objectivesDone, 'challenges completed today')
|
||
+ tile(resets.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), 'next reset (your time)');
|
||
chalRender();
|
||
})
|
||
.catch(() => { document.getElementById('chalMsg').textContent = 'Could not load the challenges.'; });
|
||
}
|
||
// "VisitACustomRoom" -> "Visit A Custom Room", "OOBE_GoToActivity" -> "OOBE Go To Activity"
|
||
function chalName(n) {
|
||
return String(n).replace(/_/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z])([A-Z][a-z])/g, '$1 $2');
|
||
}
|
||
function chalTypeOptions(sel) {
|
||
return (_chal ? _chal.types : []).map(t =>
|
||
'<option value="' + t.type + '"' + (t.type === sel ? ' selected' : '') + '>'
|
||
+ esc(chalName(t.name)) + (t.tutorialOnly ? ' (tutorial only)' : '') + '</option>').join('');
|
||
}
|
||
function chalRender() {
|
||
const perDay = document.getElementById('chalPerDay').checked;
|
||
if (perDay) while (_chalSets.length < 7) _chalSets.push(_chalSets[_chalSets.length % Math.max(1, _chalSets.length)].map(o => ({ ...o })));
|
||
else _chalSets = [_chalSets[0] || []];
|
||
const box = document.getElementById('chalSets');
|
||
box.innerHTML = _chalSets.map((set, si) =>
|
||
'<div style="border:1px solid rgba(128,128,128,.3); border-radius:8px; padding:10px 12px;">'
|
||
+ '<div style="font-weight:bold; margin-bottom:8px;">' + (perDay ? 'Day ' + (si + 1) : 'Every day') + '</div>'
|
||
+ set.map((o, oi) =>
|
||
'<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px; flex-wrap:wrap;">'
|
||
+ '<select onchange="_chalSets[' + si + '][' + oi + '].type=+this.value;" style="padding:7px; border-radius:5px; min-width:240px;">' + chalTypeOptions(o.type) + '</select>'
|
||
+ '<label style="display:flex; gap:6px; align-items:center;">target <input type="number" min="1" max="1000" value="' + o.score + '" onchange="_chalSets[' + si + '][' + oi + '].score=+this.value;" style="width:70px; padding:6px; border:1px solid #ccc; border-radius:5px;" /></label>'
|
||
+ '<button onclick="_chalSets[' + si + '].splice(' + oi + ',1); chalRender();" style="background:#7f8c8d; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">Remove</button>'
|
||
+ '</div>').join('')
|
||
+ (set.length < 6 ? '<button onclick="_chalSets[' + si + '].push({type:30,score:1}); chalRender();" style="background:#555; color:white; padding:5px 10px; font-size:13px; border:none; cursor:pointer; border-radius:5px;">+ Add challenge</button>' : '')
|
||
+ '</div>').join('');
|
||
}
|
||
function chalSave() {
|
||
const body = {
|
||
rewardEnabled: document.getElementById('chalReward').checked,
|
||
xpPerObjective: parseInt(document.getElementById('chalXp').value, 10) || 0,
|
||
sets: _chalSets
|
||
};
|
||
fetch('/admin/api/challenges/save', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||
.then(r => r.json())
|
||
.then(d => { document.getElementById('chalMsg').textContent = d.message || (d.success ? 'Saved.' : 'Failed.'); if (d.success) chalLoad(); setTimeout(() => { if (d.success) document.getElementById('chalMsg').textContent = d.message; }, 400); })
|
||
.catch(() => { document.getElementById('chalMsg').textContent = 'Could not save.'; });
|
||
}
|
||
function chalDefaults() {
|
||
if (!confirm('Put the daily challenges back to the defaults?')) return;
|
||
fetch('/admin/api/challenges/reset_defaults', { method: 'POST', credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => { chalLoad(); setTimeout(() => { document.getElementById('chalMsg').textContent = d.message || ''; }, 400); });
|
||
}
|
||
function chalPlayerLoad() {
|
||
const p = (document.getElementById('chalPlayer').value || '').trim();
|
||
const out = document.getElementById('chalPlayerOut');
|
||
if (!p) { out.textContent = 'Enter a player.'; return; }
|
||
fetch('/admin/api/challenges/player?player=' + encodeURIComponent(p), { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.success) { out.textContent = d.message || 'Failed.'; return; }
|
||
out.innerHTML = '<div style="margin-bottom:8px;"><b>@' + esc(d.name) + '</b> <span style="opacity:.6;">#' + d.id + '</span> · '
|
||
+ (d.completedToday ? '<span style="color:#27ae60; font-weight:bold;">finished all today</span>' : 'not finished today') + '</div>'
|
||
+ d.objectives.map(o => '<div style="padding:4px 0;">' + (o.done ? '✅ ' : '⬜ ')
|
||
+ esc(chalName(o.name)) + ' <span style="opacity:.6;">(' + o.progress + ' / ' + o.target + ')</span></div>').join('');
|
||
})
|
||
.catch(() => { out.textContent = 'Could not load that player.'; });
|
||
}
|
||
function chalPlayerReset() {
|
||
const p = (document.getElementById('chalPlayer').value || '').trim();
|
||
if (!p) { document.getElementById('chalPlayerOut').textContent = 'Enter a player.'; return; }
|
||
if (!confirm('Reset ' + p + "'s daily challenges? They can complete them (and get the reward) again today.")) return;
|
||
const f = new FormData(); f.append('player', p);
|
||
fetch('/admin/api/challenges/reset_player', { method: 'POST', credentials: 'same-origin', body: f })
|
||
.then(r => r.json())
|
||
.then(d => { document.getElementById('chalPlayerOut').textContent = d.message || (d.success ? 'Reset.' : 'Failed.'); });
|
||
}
|
||
|
||
// ---- Ban Logs -------------------------------------------------------------------
|
||
let _banLogsSkip = 0;
|
||
const BAN_LOG_KINDS = {
|
||
'ban': ['Ban', '#c0392b'],
|
||
'report-ban': ['Ban (report)', '#c0392b'],
|
||
'report-timeout': ['Timeout (staff report)', '#d35400'],
|
||
'chat-ban': ['Ban (chat command)', '#c0392b'],
|
||
'upload-ban': ['Ban (upload guard)', '#8e44ad'],
|
||
'votekick': ['Vote kick', '#d68910'],
|
||
'unban': ['Unban', '#27ae60'],
|
||
'roomban': ['Room ban', '#a04000'],
|
||
'roomunban': ['Room unban', '#27ae60'],
|
||
'kick': ['Kick', '#7f8c8d'],
|
||
'modkick': ['Mod kick', '#7f8c8d'],
|
||
'warning': ['Warning', '#b7950b'],
|
||
'mic-ban': ['Mic ban', '#6c3483'],
|
||
'mic-unban': ['Mic ban lifted', '#27ae60']
|
||
};
|
||
function banLogsDuration(e) {
|
||
if (['kick', 'modkick', 'warning', 'unban', 'roomunban', 'mic-unban'].includes(e.kind)) return '';
|
||
if (e.permanent) return 'permanent';
|
||
const s = e.durationSeconds || 0;
|
||
if (s <= 0) return '';
|
||
if (s % 86400 === 0) return (s / 86400) + ' day' + (s === 86400 ? '' : 's');
|
||
if (s % 3600 === 0) return (s / 3600) + ' hour' + (s === 3600 ? '' : 's');
|
||
if (s >= 60) return Math.round(s / 60) + ' min';
|
||
return s + ' s';
|
||
}
|
||
function banLogsLoad(reset) {
|
||
if (reset) _banLogsSkip = 0;
|
||
const kinds = document.getElementById('banLogsKinds').value;
|
||
const player = (document.getElementById('banLogsPlayer').value || '').trim();
|
||
let url = '/admin/api/ban_logs?take=100&skip=' + _banLogsSkip;
|
||
if (kinds) url += '&kinds=' + encodeURIComponent(kinds);
|
||
if (player) url += '&player=' + encodeURIComponent(player);
|
||
|
||
fetch(url, { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const list = document.getElementById('banLogsList');
|
||
const sum = document.getElementById('banLogsSummary');
|
||
const more = document.getElementById('banLogsMore');
|
||
if (!data.success) { if (reset) list.innerHTML = ''; sum.textContent = data.message || 'Failed.'; more.style.display = 'none'; return; }
|
||
const rows = data.rows || [];
|
||
const html = rows.map(e => {
|
||
const k = BAN_LOG_KINDS[e.kind] || [e.kind, '#555'];
|
||
const when = new Date(e.at).toLocaleString();
|
||
const dur = banLogsDuration(e);
|
||
const room = e.room ? ' · in <b>' + esc(e.room) + '</b>' : '';
|
||
const cat = e.category && e.category !== 'Moderator' && e.category !== 'Unknown' ? ' · ' + esc(e.category) : '';
|
||
return '<div style="background:var(--table-bg); border-radius:8px; padding:12px 14px;">'
|
||
+ '<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">'
|
||
+ '<div style="min-width:0;">'
|
||
+ '<span style="background:' + k[1] + '; color:#fff; border-radius:9px; padding:2px 9px; font-size:12px; margin-right:8px;">' + esc(k[0]) + '</span>'
|
||
+ '<b style="cursor:pointer;" onclick="viewPlayer(' + e.targetId + ')">@' + esc(e.target) + '</b> <span style="opacity:.6;">#' + esc(e.targetId) + '</span>'
|
||
+ (dur ? ' · <b>' + esc(dur) + '</b>' : '')
|
||
+ '</div>'
|
||
+ '<div style="opacity:.6; font-size:13px;">' + esc(when) + '</div>'
|
||
+ '</div>'
|
||
+ '<div style="opacity:.75; font-size:13px; margin-top:4px;">by <b>' + (e.byAccountId ? '@' : '') + esc(e.by) + '</b>' + room + cat + '</div>'
|
||
+ (e.message ? '<div style="margin-top:8px; background:rgba(0,0,0,.18); border-radius:6px; padding:8px 10px; white-space:pre-wrap; word-break:break-word;">' + esc(e.message) + '</div>' : '')
|
||
+ (e.note ? '<div style="margin-top:6px; opacity:.7; font-size:13px;">' + esc(e.note) + '</div>' : '')
|
||
+ '</div>';
|
||
}).join('');
|
||
if (reset) list.innerHTML = html; else list.insertAdjacentHTML('beforeend', html);
|
||
_banLogsSkip += rows.length;
|
||
sum.textContent = data.total === 0 ? 'Nothing logged yet.' : ('Showing ' + _banLogsSkip + ' of ' + data.total + ' action(s).');
|
||
more.style.display = _banLogsSkip < data.total ? 'inline-block' : 'none';
|
||
})
|
||
.catch(() => { document.getElementById('banLogsSummary').textContent = 'Could not load the ban log.'; });
|
||
}
|
||
|
||
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 ? ' — ' + 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="viewPlayer(' + 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> · ' + esc(r.categoryName) + ' · ' + room + ' · ' + 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 || '') + ' · #' + 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> · room '+esc(c.roomId)+' · sub '+esc(c.subRoomId)+' · '+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', isUgc = it.source === 'ugc';
|
||
const tag = isUgc
|
||
? '<span style="background:#8e44ad; color:#fff; border-radius:9px; padding:1px 7px; font-size:11px; margin-left:6px;">custom</span>'
|
||
: 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 = isUgc
|
||
? ('custom item ' + esc(it.customAvatarItemId))
|
||
: fromStore
|
||
? ('id ' + esc(it.purchasableItemId) + ' · store ' + esc(it.storefrontType) + ' · drop ' + esc(it.giftDropId) + ' · rarity ' + esc(it.rarity))
|
||
: (esc(it.avatarItemDesc) + ' · rarity ' + esc(it.rarity));
|
||
const args = "'" + String(it.avatarItemDesc || '').replace(/'/g, "\\'") + "','" + String(it.customAvatarItemId || '') + "'";
|
||
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, customAvatarItemId) {
|
||
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 || '');
|
||
if (customAvatarItemId) form.append('customAvatarItemId', customAvatarItemId);
|
||
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(' | ');
|
||
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(' | ')
|
||
: '<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 ? ' — ' + 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 ? ' · ' + esc(r.room) : '') + (r.category ? ' · ' + esc(r.category) : '') + ' · ' + 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> Level: <b>' + data.level + '</b>';
|
||
|
||
if (data.balances && data.balances.length) {
|
||
html += '<br><br><b>Balances</b><br>';
|
||
data.balances.forEach(b => {
|
||
html += ' ' + 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) {
|
||
// "#Section" (dashboard quick links) opens a panel section instead of leaving the page.
|
||
if (typeof data_tab === 'string' && data_tab.charAt(0) === '#') { setup_html_show(data_tab.slice(1)); return; }
|
||
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);
|
||
});
|
||
}
|
||
|
||
|
||
// ---- mic bans ----
|
||
// h:mm:ss (or "Nd h:mm:ss") - the popup's live countdown for a running mic / chat ban.
|
||
function chatBanClock(sec) {
|
||
sec = Math.max(0, Math.floor(sec));
|
||
const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
|
||
const hms = `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||
return d > 0 ? `${d}d ${hms}` : hms;
|
||
}
|
||
setInterval(() => {
|
||
document.querySelectorAll('.chatban-left').forEach(el => {
|
||
const left = (Number(el.dataset.ends) - Date.now()) / 1000;
|
||
el.textContent = left > 0 ? chatBanClock(left) : 'ended';
|
||
});
|
||
}, 1000);
|
||
let _micBanTarget = null;
|
||
function escapeHtmlMic(t) { return String(t).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function openMicBan(accountId, username) {
|
||
_micBanTarget = { accountId, username };
|
||
document.getElementById('micban-title').textContent = 'Mic ban @' + username;
|
||
document.getElementById('micban-reason').value = '';
|
||
document.getElementById('micban-amount').value = '1';
|
||
document.getElementById('micban-unit').value = '60';
|
||
document.getElementById('micban-permanent').checked = false;
|
||
document.getElementById('micban-amount').disabled = document.getElementById('micban-unit').disabled = false; closePopup('player-popup');
|
||
document.getElementById('micban-popup').classList.add('active');
|
||
}
|
||
async function submitMicBan() {
|
||
if (!_micBanTarget) return;
|
||
const fd = new FormData();
|
||
fd.append('playerId', _micBanTarget.accountId);
|
||
const perm = document.getElementById('micban-permanent').checked;
|
||
const amount = Math.floor(Number(document.getElementById('micban-amount').value));
|
||
if (!perm && (!amount || amount < 1)) { showPopup('Failed', 'Enter how long, or tick Permanent.'); return; }
|
||
fd.append('minutes', perm ? '0' : String(amount * Number(document.getElementById('micban-unit').value)));
|
||
fd.append('reason', document.getElementById('micban-reason').value);
|
||
try {
|
||
const r = await fetch('/admin/api/mic_ban', { method: 'POST', body: fd });
|
||
const d = await r.json();
|
||
closePopup('micban-popup');
|
||
closePopup('player-popup');
|
||
showPopup(d.success ? 'Mic banned' : 'Failed', escapeHtmlMic(d.message || ''));
|
||
} catch (e) { showPopup('Error', 'An error occurred: ' + e.message); }
|
||
}
|
||
async function liftMicBan(accountId, username) {
|
||
const fd = new FormData();
|
||
fd.append('playerId', accountId);
|
||
try {
|
||
const r = await fetch('/admin/api/mic_unban', { method: 'POST', body: fd });
|
||
const d = await r.json();
|
||
closePopup('player-popup');
|
||
showPopup(d.success ? 'Mic ban' : 'Failed', escapeHtmlMic(d.message || ''));
|
||
} catch (e) { showPopup('Error', 'An error occurred: ' + e.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) {
|
||
// A section with a side-menu entry opens through it, so it is highlighted and its
|
||
// loader runs exactly as when picked from the menu.
|
||
const menuTab = document.querySelector('.tab[data-tab="' + data_tab + '"]');
|
||
if (menuTab) { menuTab.click(); return; }
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
if (data_tab === 'Inventions' && typeof loadInventions === 'function') loadInventions();
|
||
if (data_tab === 'Clubs' && typeof loadClubs === 'function') loadClubs();
|
||
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));
|
||
});
|
||
}
|
||
|
||
// ---- Normal-player whitelist (launcher) ---------------------------------
|
||
let _launcherPlayers = [], _launcherRequired = true;
|
||
function launcherPlayersLoad() {
|
||
fetch('/admin/api/launcher_players', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const state = document.getElementById('launcherPlayersState');
|
||
if (!state) return;
|
||
if (data.success === false) { state.textContent = data.message || 'Could not load the list.'; return; }
|
||
_launcherPlayers = data.entries || [];
|
||
_launcherRequired = !!data.required;
|
||
state.innerHTML = (_launcherRequired
|
||
? '<span style="color:#7CFC9B">REQUIRED</span> - PC players must come through the launcher (or be on the Steam Allowlist). '
|
||
: '<span style="color:#FFC46B">NOT REQUIRED</span> - only the Steam Allowlist gates Steam logins. ')
|
||
+ data.count + ' Steam ID(s) whitelisted through the launcher.';
|
||
const btn = document.getElementById('launcherReqBtn');
|
||
if (btn) { btn.textContent = _launcherRequired ? 'Turn requirement OFF' : 'Turn requirement ON'; btn.className = _launcherRequired ? 'btn btn-danger' : 'btn btn-info'; }
|
||
launcherPlayersShow();
|
||
})
|
||
.catch(err => { const st = document.getElementById('launcherPlayersState'); if (st) st.textContent = 'Could not load the list: ' + err; });
|
||
}
|
||
function launcherPlayersShow() {
|
||
const list = document.getElementById('launcherPlayersList');
|
||
if (!list) return;
|
||
const q = ((document.getElementById('launcherPlayersFilter') || {}).value || '').trim().toLowerCase();
|
||
const rows = _launcherPlayers.filter(e => !q || (e.username || '').toLowerCase().includes(q) || (e.steamId || '').includes(q));
|
||
if (!rows.length) { list.innerHTML = '<p style="opacity:.6">' + (q ? 'No matches.' : 'Nobody yet - players are added when they sign in to the launcher.') + '</p>'; return; }
|
||
list.innerHTML = rows.map(e => {
|
||
const when = e.addedAt ? new Date(e.addedAt).toLocaleString() : '';
|
||
const who = e.username ? '@' + escapeHtml(e.username) + ' (id ' + e.accountId + ')' : 'account ' + e.accountId;
|
||
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 style="opacity:.55">- added ' + when + '</span></span>' +
|
||
'<button class="btn btn-danger" onclick="launcherPlayerRemove("' + e.steamId + '")">Remove</button></div>';
|
||
}).join('');
|
||
}
|
||
function launcherPlayerRemove(id) {
|
||
const fd = new FormData(); fd.append('steamId', id);
|
||
fetch('/admin/api/launcher_players/remove', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then(r => r.json()).then(d => { showPopup(d.success ? 'Success!' : 'Error!', d.message || ''); launcherPlayersLoad(); })
|
||
.catch(err => showPopup('Error!', String(err)));
|
||
}
|
||
function launcherRequiredToggle() {
|
||
const on = !_launcherRequired;
|
||
if (!on && !confirm('Turn the launcher requirement OFF? Then only the Steam Allowlist gates PC logins (an empty allowlist lets every Steam user in).')) return;
|
||
const fd = new FormData(); fd.append('on', on ? 'true' : 'false');
|
||
fetch('/admin/api/launcher_players/required', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then(r => r.json()).then(d => { showPopup(d.success ? 'Success!' : 'Error!', d.message || ''); launcherPlayersLoad(); })
|
||
.catch(err => showPopup('Error!', String(err)));
|
||
}
|
||
|
||
// ---- 2FA (AdminController.cs /admin/api/2fa/*) ----
|
||
function secShow(which) {
|
||
document.getElementById('secOff').style.display = which === 'off' ? 'block' : 'none';
|
||
document.getElementById('secSetup').style.display = which === 'setup' ? 'block' : 'none';
|
||
document.getElementById('secRecovery').style.display = which === 'recovery' ? 'block' : 'none';
|
||
document.getElementById('secOn').style.display = which === 'on' ? 'block' : 'none';
|
||
}
|
||
function secLoad() {
|
||
fetch('/admin/api/2fa/status', { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
const state = document.getElementById('secState');
|
||
if (!d.success) { state.textContent = 'Could not load.'; return; }
|
||
state.innerHTML = d.enabled ? '<span style="color:#7CFC9B">ON</span> - a code from your app is required to log in.'
|
||
: '<span style="color:#FFC46B">OFF</span> - your password alone signs you in.';
|
||
secShow(d.enabled ? 'on' : 'off');
|
||
}).catch(() => { document.getElementById('secState').textContent = 'Could not load.'; });
|
||
}
|
||
function secBeginSetup() {
|
||
fetch('/admin/api/2fa/setup', { method: 'POST', credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
if (!d.success) { alert(d.message || 'Could not start setup.'); return; }
|
||
document.getElementById('secSecret').textContent = d.secret;
|
||
document.getElementById('secCode').value = '';
|
||
secShow('setup');
|
||
});
|
||
}
|
||
function secCancelSetup() { secShow('off'); }
|
||
function secConfirm() {
|
||
const code = document.getElementById('secCode').value.trim();
|
||
if (!code) return;
|
||
fetch('/admin/api/2fa/confirm', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })
|
||
.then(r => r.json()).then(d => {
|
||
if (!d.success) { alert(d.message || 'Wrong code.'); return; }
|
||
document.getElementById('secRecoveryList').textContent = d.recoveryCodes.join('\n');
|
||
secShow('recovery');
|
||
});
|
||
}
|
||
function secDisable() {
|
||
if (!confirm('Turn off 2FA for your account? Your password alone will sign you in again.')) return;
|
||
fetch('/admin/api/2fa/disable', { method: 'POST', credentials: 'same-origin' }).then(r => r.json()).then(() => secLoad());
|
||
}
|
||
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("' + e.steamId + '")">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(); launcherPlayersLoad(); secLoad(); }
|
||
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') === 'BanLogs'
|
||
&& typeof banLogsLoad === 'function') banLogsLoad(true);
|
||
if (tab.getAttribute('data-tab') === 'Challenges'
|
||
&& typeof chalLoad === 'function') { chalLoad(); if (typeof evLoad === 'function') evLoad(); if (typeof wkLoad === 'function') wkLoad(); }
|
||
if (tab.getAttribute('data-tab') === 'Stores' && typeof stInit === 'function') stInit();
|
||
if (tab.getAttribute('data-tab') === 'DbEditor' && typeof dbInit === 'function') dbInit();
|
||
if (tab.getAttribute('data-tab') === 'RecflareCdn' && typeof rfInit === 'function') rfInit();
|
||
if (tab.getAttribute('data-tab') === 'ArchiveRooms' && typeof arcInit === 'function') arcInit();
|
||
if (tab.getAttribute('data-tab') === 'RoomEditor' && typeof rteInit === 'function') rteInit();
|
||
if (tab.getAttribute('data-tab') === 'Videos' && typeof vidLoad === 'function') vidLoad();
|
||
if (tab.getAttribute('data-tab') === 'Chat' && typeof chatLoad === 'function') chatLoad();
|
||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
// 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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||
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, "&")
|
||
.replace(/</g, "<").replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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 dev-only-action" 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 dev-only-action ${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 dev-only-action ${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 dev-only-action ${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 dev-only-action ${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 dev-only-action ${(player.IsStudio || player.IsDev) ? 'role-on' : ''}" ${player.IsDev ? 'disabled' : ''}
|
||
data-id="${player.AccountId}" data-role="studio" data-on="${player.IsStudio ? 1 : 0}"
|
||
title="${player.IsDev ? 'Devs always have Studio (Campus Studio publishing)' : 'Campus Studio: can publish recoloured items from the app'}" onclick="toggleRole(this)">Studio</button>
|
||
<button class="btn btn-role dev-only-action ${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"
|
||
onclick="goto_ban('${attrJs(player.Username)}', '${player.AccountId}', '${attrJs(player.ProfileImage)}', null)">Ban</button>
|
||
<button class="btn btn-danger mod-only-action"
|
||
onclick="timeoutPlayer('${player.AccountId}', '${attrJs(player.Username)}')">Timeout</button>
|
||
<button class="btn btn-danger btn-delete-player dev-only-action"
|
||
onclick="deletePlayer('${player.AccountId}', '${attrJs(player.Username)}')">Delete</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
document.getElementById("player-list").innerHTML = playerList;
|
||
}
|
||
|
||
// Mod-only quick action (POST /admin/api/timeout_player): a fixed 10-minute block, no dialog.
|
||
// Devs use the full Ban button/dialog for anything longer or more specific.
|
||
function timeoutPlayer(accountId, username) {
|
||
if (!confirm(`Time out @${username} for 10 minutes?`)) return;
|
||
const fd = new FormData();
|
||
fd.append('playerId', accountId);
|
||
fetch('/admin/api/timeout_player', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => showPopup(data.success ? 'Success!' : 'Error!', data.message || (data.success ? 'Done.' : 'Failed.')))
|
||
.catch(err => showPopup('Error!', 'Timeout failed: ' + err));
|
||
}
|
||
|
||
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 dev-only-action" 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 dev-only-action ${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 dev-only-action ${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 dev-only-action ${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 dev-only-action ${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 dev-only-action ${(player.IsStudio || player.IsDev) ? 'role-on' : ''}" ${player.IsDev ? 'disabled' : ''}
|
||
data-id="${player.AccountId}" data-role="studio" data-on="${player.IsStudio ? 1 : 0}"
|
||
title="${player.IsDev ? 'Devs always have Studio (Campus Studio publishing)' : 'Campus Studio: can publish recoloured items from the app'}" onclick="toggleRole(this)">Studio</button>
|
||
<button class="btn btn-role dev-only-action ${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}"
|
||
onclick="goto_ban('${attrJs(player.Username)}', '${player.AccountId}', '${attrJs(player.ProfileImage)}', null)">Ban</button>
|
||
<button class="btn btn-danger mod-only-action"
|
||
onclick="timeoutPlayer('${player.AccountId}', '${attrJs(player.Username)}')">Timeout</button>
|
||
<button class="btn btn-danger btn-delete-player dev-only-action"
|
||
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}" onclick="deleteRoom('${room.RoomId}', '${attrJs(name)}')">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>@${username} is not currently banned</strong>
|
||
`;
|
||
}
|
||
|
||
banStatusDiv.style.display = "flex";
|
||
// Mic ban status, under the ban line.
|
||
try {
|
||
const b = await fetchAsync(`/admin/api/mic_ban/${accountId}`);
|
||
if (b && b.active) {
|
||
const label = 'Mic banned', icon = 'fa-microphone-slash';
|
||
const left = b.permanent ? 'permanent' : `<span class="chatban-left" data-ends="${Date.now() + b.remainingSeconds * 1000}">${chatBanClock(b.remainingSeconds)}</span> left`;
|
||
banStatusDiv.insertAdjacentHTML('beforeend', `<div style="margin-top:6px;"><i class="fas ${icon}"></i> <strong>${label}</strong> (${left})${b.message ? ' - ' + escapeHtmlMic(b.message) : ''}</div>`);
|
||
banStatusDiv.style.flexDirection = 'column';
|
||
}
|
||
} catch (e) { console.error('mic ban status', e); }
|
||
}
|
||
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-secondary" onclick="closePopup('player-popup')">Close</button>
|
||
<button class="btn btn-primary" 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, '"');
|
||
|
||
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-danger" onclick="openMicBan('${accountId}', '${username}')">Mic ban @${username}</button>
|
||
<button class="btn btn-secondary" onclick="liftMicBan('${accountId}', '${username}')">Lift mic ban</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");
|
||
}
|
||
|
||
// (The old per-panel colour themes are gone: the panel follows the website's theme, see
|
||
// Settings -> Appearance and /admin/home_page.js.)
|
||
try { localStorage.removeItem('selectedTheme'); localStorage.removeItem('backgroundImage');
|
||
localStorage.removeItem('mobileSupport'); localStorage.removeItem('betterPopup'); } catch (e) { }
|
||
|
||
// A mod account reaches this page (AdminHomeVVersion accepts dev-or-mod now), but only gets a
|
||
// fixed, server-decided slice of tabs: Dashboard, Players, Reports, Ban Logs, Chat. Every other
|
||
// sidebar tab is hidden - and hiding is enough, not just cosmetic: every endpoint those tabs call
|
||
// still independently requires ValidateTokenDev (not OrMod), so a mod poking at hidden UI or
|
||
// calling a route directly gets 401 either way. This only decides what a mod's browser SHOWS.
|
||
const MOD_ALLOWED_TABS = new Set(['home', 'Players', 'Reports', 'BanLogs', 'Chat']);
|
||
async function applyRoleUI() {
|
||
let who;
|
||
try { who = await fetchAsync('/admin/api/whoami'); } catch (e) { console.error('whoami failed:', e); return; }
|
||
if (!who || !who.success || who.isDev) return; // full dev/owner access: nothing to hide
|
||
|
||
document.querySelectorAll('.admin-sidebar .tab').forEach(btn => {
|
||
// .role-hidden, not inline style: .tab carries display:flex !important in the
|
||
// stylesheet, which a plain style.display='none' cannot win against.
|
||
if (!MOD_ALLOWED_TABS.has(btn.getAttribute('data-tab'))) btn.classList.add('role-hidden');
|
||
});
|
||
// Force Join moves a player's room on the spot - a moderation-adjacent action, not part of
|
||
// the "Players, view only" slice a mod was given, so it's hidden rather than left to 401.
|
||
const fj = document.querySelector('.fj-card');
|
||
if (fj) fj.style.display = 'none';
|
||
// Dashboard quick-stat cards that link straight into an out-of-scope tab (Log, Rooms, Clubs,
|
||
// Inventions) - these aren't sidebar .tab buttons so MOD_ALLOWED_TABS never touched them.
|
||
['dashCardLogs', 'dashCardRooms', 'dashCardClubs', 'dashCardInventions'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.classList.add('role-hidden');
|
||
});
|
||
// Players table: hides the Platform/Device/Created/Last-Online columns and swaps the row
|
||
// actions down to View Player + Ban + Timeout (see the #Players.mod-view CSS rules).
|
||
const playersPanel = document.getElementById('Players');
|
||
if (playersPanel) playersPanel.classList.add('mod-view');
|
||
}
|
||
|
||
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);
|
||
step('applyRoleUI', applyRoleUI);
|
||
}
|
||
|
||
initDashboard();
|
||
|
||
// ---- Database editor ----------------------------------------------------------------
|
||
// /admin/api/db/*: browse, search, edit, add and delete LiteDB documents (AdminDatabaseController).
|
||
// Document ids travel as LiteDB JSON ({"$numberLong":"227"}, "abc", 12 ...) so every id type round-trips.
|
||
let _dbList = [], _dbSkip = 0, _dbDocId = null, _dbDirty = false;
|
||
function dbEsc(t) { return String(t == null ? '' : t).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function dbMsg(t, bad) { const m = document.getElementById('dbMsg'); m.textContent = t || ''; m.style.color = bad ? '#e74c3c' : ''; }
|
||
function dbCur() { return { db: document.getElementById('dbSelect').value, col: document.getElementById('dbCol').value }; }
|
||
function dbSize(n) { return n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB'; }
|
||
// ---- Room Editor tab (AdminRoomToolsController) ----
|
||
let _rte = null;
|
||
const RTE_BTN = 'background:var(--card-hover-bg); color:white; padding:6px 12px; font-size:14px; border:none; cursor:pointer; border-radius:5px;';
|
||
const RTE_IN = 'padding:6px; font-size:14px; border:1px solid #ccc; border-radius:5px;';
|
||
function rteInit() { const q = new URLSearchParams(location.search).get('room'); if (!_rte && q) rteLoad(q); }
|
||
function vidSay(t, bad) { const m = document.getElementById('vidProg'); m.textContent = t || ''; m.style.color = bad ? '#e74c3c' : '#2ecc71'; }
|
||
function vidSize(n) { return n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(0) + ' KB' : n + ' bytes'; }
|
||
function vidLoad() {
|
||
fetch('/admin/api/videos', { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
const el = document.getElementById('vidBody');
|
||
if (!d.success) { el.textContent = d.message || 'Could not load.'; return; }
|
||
if (!d.videos.length) { el.textContent = 'No videos yet.'; return; }
|
||
let h = '<table style="width:100%; border-collapse:collapse;"><tr style="text-align:left;"><th>Name</th><th>Size</th><th>Codecs</th><th>Address</th><th></th></tr>';
|
||
for (const v of d.videos) h += `<tr><td>${dbEsc(v.name)}${v.youtube ? '<br><small style="opacity:.75;">"' + dbEsc(v.youtube.title) + '" by ' + dbEsc(v.youtube.channel) + ' (CC BY) - deleted ' + dbEsc(String(v.youtube.expires).slice(0, 10)) + '</small>' : ''}</td><td>${vidSize(v.size)}</td><td>${dbEsc(v.codecs || '?')}</td>` +
|
||
`<td><input type="text" readonly value="${dbEsc(v.url)}" onclick="this.select();" style="width:95%; padding:4px;"></td>` +
|
||
`<td><button onclick="vidCopy(this);" data-url="${dbEsc(v.url)}">Copy</button> <button onclick="vidDelete(this);" data-name="${dbEsc(v.name)}">Delete</button></td></tr>`;
|
||
el.innerHTML = h + '</table>';
|
||
}).catch(() => { document.getElementById('vidBody').textContent = 'Could not load.'; });
|
||
}
|
||
function vidYoutube() {
|
||
const url = document.getElementById('vidYt').value.trim(), btn = document.getElementById('vidYtBtn');
|
||
if (!url) { vidSay('Paste a YouTube link first.', true); return; }
|
||
btn.disabled = true; vidSay('Checking the licence and fetching... this can take a minute.');
|
||
fetch('/admin/api/videos/youtube', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) })
|
||
.then(r => r.json()).then(d => { vidSay(d.message, !d.success); if (d.success) document.getElementById('vidYt').value = ''; vidLoad(); })
|
||
.catch(() => vidSay('The request failed.', true)).finally(() => { btn.disabled = false; });
|
||
}
|
||
function vidCopy(b) { try { navigator.clipboard.writeText(b.dataset.url); b.textContent = 'Copied'; setTimeout(() => b.textContent = 'Copy', 1500); } catch (e) { } }
|
||
function vidDelete(b) {
|
||
const name = b.dataset.name;
|
||
if (!confirm('Delete ' + name + '? Rooms that use it will stop playing it.')) return;
|
||
fetch('/admin/api/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) })
|
||
.then(r => r.json()).then(d => { vidSay(d.message, !d.success); vidLoad(); });
|
||
}
|
||
function vidUpload() {
|
||
const f = document.getElementById('vidFile').files[0];
|
||
if (!f) { vidSay('Pick an .mp4 file first.', true); return; }
|
||
const fd = new FormData();
|
||
fd.append('file', f); fd.append('name', document.getElementById('vidName').value); fd.append('replace', document.getElementById('vidReplace').checked ? 'true' : 'false');
|
||
const x = new XMLHttpRequest();
|
||
x.open('POST', '/admin/api/videos/upload'); x.withCredentials = true;
|
||
x.upload.onprogress = e => { if (e.lengthComputable) vidSay('Uploading... ' + Math.round(e.loaded * 100 / e.total) + '% (' + vidSize(e.loaded) + ' of ' + vidSize(e.total) + ')'); };
|
||
x.onload = () => {
|
||
let d = {}; try { d = JSON.parse(x.responseText); } catch (e) { d = { success: false, message: 'Upload failed (HTTP ' + x.status + ').' }; }
|
||
vidSay(d.message || (d.success ? 'Uploaded.' : 'Failed.'), !d.success);
|
||
if (d.success) { document.getElementById('vidFile').value = ''; document.getElementById('vidName').value = ''; }
|
||
vidLoad();
|
||
};
|
||
x.onerror = () => vidSay('Upload failed (network).', true);
|
||
vidSay('Uploading...');
|
||
x.send(fd);
|
||
}
|
||
// ---- Chat tab (AdminChatController) - every DM/group thread, read-only ----
|
||
let _chatOpenId = null;
|
||
function chatTime(iso) { if (!iso) return ''; const ms = Date.now() - new Date(iso).getTime(), m = Math.floor(ms / 60000); if (m < 1) return 'now'; if (m < 60) return m + 'm'; const h = Math.floor(m / 60); if (h < 24) return h + 'h'; return Math.floor(h / 24) + 'd'; }
|
||
function chatTitle(t) {
|
||
if (t.isGroup) return t.name || t.members.map(m => m.display_name).join(', ');
|
||
const others = t.members; return others.length ? others.map(m => m.display_name).join(', ') : '(empty thread)';
|
||
}
|
||
function chatLoad() {
|
||
const q = document.getElementById('chatQ').value.trim();
|
||
fetch('/admin/api/chat/threads?q=' + encodeURIComponent(q) + '&take=100', { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
const el = document.getElementById('chatThreads'), note = document.getElementById('chatNote');
|
||
if (!d.success) { el.innerHTML = '<div style="opacity:.6; padding:8px;">Could not load.</div>'; return; }
|
||
note.textContent = d.total + ' thread' + (d.total === 1 ? '' : 's') + (d.note || '');
|
||
if (!d.threads.length) { el.innerHTML = '<div style="opacity:.6; padding:8px;">No threads match.</div>'; return; }
|
||
el.innerHTML = d.threads.map(t => `<div onclick="chatOpen(${t.id});" style="padding:9px 10px; border-radius:7px; cursor:pointer; margin-bottom:3px; ${t.id === _chatOpenId ? 'background:var(--card-hover-bg); color:white;' : ''}">
|
||
<div style="font-weight:600; font-size:14px;">${dbEsc(chatTitle(t))}${t.type === 1 ? ' <span style="opacity:.6; font-weight:400;">(party)</span>' : ''}</div>
|
||
<div style="font-size:12px; opacity:.75; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${dbEsc(t.latestText || '(no messages)')} - ${chatTime(t.latestAt)} - ${t.messageCount} msg${t.messageCount === 1 ? '' : 's'}</div>
|
||
</div>`).join('');
|
||
}).catch(() => { document.getElementById('chatThreads').innerHTML = '<div style="opacity:.6; padding:8px;">Could not load.</div>'; });
|
||
}
|
||
function chatOpen(id) {
|
||
_chatOpenId = id;
|
||
const wrap = document.getElementById('chatTranscriptWrap');
|
||
wrap.innerHTML = '<div style="opacity:.6;">Loading...</div>';
|
||
fetch('/admin/api/chat/thread/' + id, { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
if (!d.success) { wrap.innerHTML = '<div style="opacity:.6;">Could not load that thread.</div>'; return; }
|
||
const t = d.thread;
|
||
const head = `<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; flex-wrap:wrap; gap:8px;">
|
||
<div><b>${dbEsc(chatTitle(t))}</b> <span style="opacity:.6;">thread ${t.id} - members: ${t.members.map(m => '@' + dbEsc(m.username) + ' (#' + m.id + ')').join(', ')}</span></div>
|
||
<a href="/admin/api/chat/thread/${t.id}/transcript.txt" style="${RTE_BTN}" download>Download .txt</a>
|
||
</div>`;
|
||
const body = d.messages.map(m => `<div style="margin-bottom:10px; ${m.system ? 'text-align:center; opacity:.6; font-size:12px;' : ''}">
|
||
${m.system ? dbEsc(m.text) : `<div style="font-size:12px; opacity:.7;">@${dbEsc(m.sender.username)} (#${m.senderId}) - ${new Date(m.at).toLocaleString()}</div><div style="white-space:pre-wrap; word-break:break-word;">${dbEsc(m.text)}</div>`}
|
||
</div>`).join('');
|
||
wrap.innerHTML = head + (body || '<div style="opacity:.6;">No messages.</div>');
|
||
chatLoad(); // repaint the list so the active row highlights
|
||
}).catch(() => { wrap.innerHTML = '<div style="opacity:.6;">Could not load that thread.</div>'; });
|
||
}
|
||
function rteSay(t, bad) { const m = document.getElementById('rteMsg'); m.textContent = t || ''; m.style.color = bad ? '#e74c3c' : '#2ecc71'; }
|
||
function rteSize(n) { return n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(0) + ' KB' : n + ' bytes'; }
|
||
function rtePost(url, obj) { return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj) }).then(r => r.json()); }
|
||
function rteForm(url, obj) { const f = new URLSearchParams(); for (const k in obj) f.append(k, obj[k]); return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: f }).then(r => r.json()); }
|
||
function rteFind() {
|
||
const q = document.getElementById('rteQ').value.trim(), out = document.getElementById('rteFound');
|
||
if (!q) { out.textContent = ''; return; }
|
||
fetch('/admin/api/roomtools/find?q=' + encodeURIComponent(q), { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
out.innerHTML = (d.rooms || []).map(r => `<div style="margin-top:6px;"><a href="#" onclick="rteLoad(${Number(r.id)}); return false;" style="font-weight:bold;">^${dbEsc(r.name)}</a> <span style="opacity:.65;">room ${Number(r.id)}${r.creator ? ', by @' + dbEsc(r.creator) : ''}, ${Number(r.subrooms)} subroom(s)${r.dorm ? ', a dorm' : ''}</span></div>`).join('') || '<div style="opacity:.7;">No room matches.</div>';
|
||
});
|
||
}
|
||
function rteLoad(id) {
|
||
rteSay('Loading...', false);
|
||
fetch('/admin/api/roomtools/room?id=' + encodeURIComponent(id), { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
if (!d.success) { rteSay(d.message || 'Could not load the room.', true); return; }
|
||
_rte = d; rteSay('', false); rteRender();
|
||
}).catch(() => rteSay('Could not load the room.', true));
|
||
}
|
||
function rteReload() { if (_rte) rteLoad(_rte.room.id); }
|
||
function rteRender() {
|
||
const d = _rte, r = d.room;
|
||
const flag = (id, label, on) => `<label style="margin-right:14px; white-space:nowrap;"><input type="checkbox" id="${id}" ${on ? 'checked' : ''}/> ${label}</label>`;
|
||
let h = `<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<h3 style="margin-top:0;">^${dbEsc(r.name)} <span style="opacity:.6; font-weight:normal; font-size:14px;">room ${r.id}${r.creator ? ', made by @' + dbEsc(r.creator) : ''}</span></h3>
|
||
<div style="margin-bottom:8px;"><b>Shown on:</b> ${flag('rteF_screens', 'Screens', r.supportsScreens)}${flag('rteF_walk', 'Walk VR', r.supportsWalkVR)}${flag('rteF_tele', 'Teleport VR', r.supportsTeleportVR)}${flag('rteF_vrlow', 'VR (low)', r.supportsVRLow)}${flag('rteF_q2', 'Quest 2', r.supportsQuest2)}${flag('rteF_mob', 'Mobile', r.supportsMobile)}${flag('rteF_jr', 'Juniors', r.supportsJuniors)}</div>
|
||
<div style="margin-bottom:8px;">Room data file (the room-level .META): <input type="text" id="rteMeta" value="${dbEsc((r.roomBlob && r.roomBlob.name) || '')}" style="${RTE_IN} width:340px;" /> ${r.roomBlob && r.roomBlob.name ? (r.roomBlob.exists ? '<span style="color:#2ecc71;">on the server (' + rteSize(r.roomBlob.size) + ')</span>' : '<span style="color:#e74c3c;">missing</span>') : ''}</div>
|
||
<button onclick="rteSaveRoom();" style="${RTE_BTN}">Save these</button></div>`;
|
||
h += (r.dorm ? '<div style="opacity:.7; margin-bottom:12px;">This is a player\'s dorm.</div>' : '');
|
||
for (const sub of d.subRooms) h += rteSubHtml(sub);
|
||
h += rteMoveHtml();
|
||
document.getElementById('rteBody').innerHTML = h;
|
||
rteBakedInfo();
|
||
}
|
||
function rteSubHtml(sub) {
|
||
const id = sub.id, cur = sub.current;
|
||
const acc = ['Private', 'Public', 'Unlisted', 'Dev only', 'Dev unlisted'].map((n, i) => `<option value="${i}" ${sub.accessibility === i ? 'selected' : ''}>${n}</option>`).join('');
|
||
const file = cur ? (cur.file.exists ? `<a href="/admin/api/roomtools/blob?name=${encodeURIComponent(cur.file.name)}">${dbEsc(cur.file.name)}</a> <span style="opacity:.7;">(${rteSize(cur.file.size)})</span>` : `<span style="color:#e74c3c;">${dbEsc(cur.file.name || '(none)')} - missing on the server</span>`) : '<span style="opacity:.7;">no save yet</span>';
|
||
const hist = (sub.saves || []).map(sv => `<tr><td>${sv.id}</td><td style="font-family:monospace; font-size:12px;">${dbEsc(sv.file.name || '')}${sv.file.exists ? '' : ' <span style="color:#e74c3c;">(missing)</span>'}</td><td>${new Date(sv.created).toLocaleString()}</td><td>${dbEsc(sv.description)}</td><td>${cur && cur.id === sv.id ? '<b>live</b>' : `<button onclick="rteRestore(${id}, ${sv.id});" style="${RTE_BTN}">Make live</button>`}${sv.file.exists ? ` <a href="/admin/api/roomtools/blob?name=${encodeURIComponent(sv.file.name)}">download</a>` : ''}</td></tr>`).join('');
|
||
const perms = rtePermGrid(sub);
|
||
return `<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;" id="rteSub_${id}">
|
||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;"><h3 style="margin:0;">Subroom: ${dbEsc(sub.name)} <span style="opacity:.6; font-weight:normal; font-size:14px;">id ${id}</span></h3>
|
||
<label style="margin-left:auto;"><input type="checkbox" class="rteMoveSel" value="${id}" /> pick for moving</label></div>
|
||
<div style="margin:10px 0; display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<label>Name <input id="rteN_${id}" value="${dbEsc(sub.name)}" style="${RTE_IN} width:170px;" /></label>
|
||
<label>Max players <input id="rteM_${id}" type="number" min="1" max="100" value="${sub.maxPlayers}" style="${RTE_IN} width:70px;" /></label>
|
||
<label>Accessibility <select id="rteA_${id}" style="${RTE_IN}">${acc}</select></label>
|
||
<label><input id="rteS_${id}" type="checkbox" ${sub.sandbox ? 'checked' : ''}/> Sandbox (building allowed)</label>
|
||
<label>Scene <input id="rteC_${id}" value="${dbEsc(sub.scene || '')}" style="${RTE_IN} width:300px; font-family:monospace; font-size:12px;" /></label>
|
||
<button onclick="rteSaveSub(${id});" style="${RTE_BTN}">Save subroom</button></div>
|
||
<h4 style="margin:14px 0 6px 0;">Save file</h4>
|
||
<div>Live: ${file}${cur ? ` <span style="opacity:.7;">- save ${cur.id}, versions ${cur.persistenceVersion}/${cur.omVersion}/${cur.ugcSubVersion}</span>` : ''}</div>
|
||
${cur ? `<div style="margin:8px 0; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">Edit this save:
|
||
<input id="rteD_${id}" value="${dbEsc(cur.description)}" placeholder="notes" style="${RTE_IN} width:220px;" />
|
||
<input id="rteP_${id}" type="number" value="${cur.persistenceVersion}" title="persistence version" style="${RTE_IN} width:80px;" />
|
||
<input id="rteO_${id}" type="number" value="${cur.omVersion}" title="OM version" style="${RTE_IN} width:60px;" />
|
||
<input id="rteU_${id}" type="number" value="${cur.ugcSubVersion}" title="UGC sub version" style="${RTE_IN} width:80px;" />
|
||
<input id="rteFile_${id}" value="${dbEsc(cur.file.name || '')}" title="blob file name" style="${RTE_IN} width:280px; font-family:monospace; font-size:12px;" />
|
||
<button onclick="rteSaveInfo(${id}, ${cur.id});" style="${RTE_BTN}">Save details</button></div>` : ''}
|
||
<div style="margin:8px 0; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">Baked bundle (the 25-125 MB download): <span id="rteB_${id}" style="opacity:.8;">checking...</span>
|
||
<button onclick="rteBaked(${id}, 'remove');" style="${RTE_BTN}">Don't use it (the game bakes the room itself)</button> <button onclick="rteBaked(${id}, 'restore');" style="${RTE_BTN}">Use it again</button></div>
|
||
<div style="margin:8px 0; display:flex; gap:8px; flex-wrap:wrap; align-items:center;">Upload a new .room file (goes live at once):
|
||
<input type="file" id="rteUp_${id}" accept=".room,.META,.bin,*/*" /><input id="rteUD_${id}" placeholder="notes (optional)" style="${RTE_IN} width:200px;" />
|
||
<button onclick="rteUpload(${id});" style="${RTE_BTN}">Upload</button></div>
|
||
<details style="margin:8px 0;"><summary style="cursor:pointer;">Saves (${(sub.saves || []).length})</summary>
|
||
<table style="margin-top:6px; font-size:13px; border-spacing:10px 3px;"><tr style="opacity:.6;"><td>id</td><td>file</td><td>saved</td><td>notes</td><td></td></tr>${hist}</table></details>
|
||
<details style="margin:8px 0;"><summary style="cursor:pointer;">Permissions (who may do what in this subroom)</summary>${perms}</details>
|
||
<div style="margin-top:8px;"><button onclick="rteDeleteSub(${id});" style="${RTE_BTN} background:#c0392b;">Delete this subroom</button></div></div>`;
|
||
}
|
||
function rtePermGrid(sub) {
|
||
const d = _rte, id = sub.id;
|
||
const byKey = {}; for (const c of sub.permissions) byKey[c.permission + '|' + c.role] = c;
|
||
const head = d.roles.map(r => `<th style="padding:3px 8px;">${dbEsc(r.name)}</th>`).join('');
|
||
const rows = d.permissionNames.map(p => `<tr><td style="padding:3px 8px; font-family:monospace; font-size:12px;">${dbEsc(p.replace('CAN_', '').replace(/_/g, ' ').toLowerCase())}</td>` + d.roles.map(r => {
|
||
const c = byKey[p + '|' + r.role]; const state = !c ? 'default' : (c.overridden ? (c.value === 'False' ? 'deny' : 'allow') : 'default');
|
||
const def = c ? (c.defaultValue === 'False' ? 'no' : 'yes') : '?';
|
||
return `<td style="padding:2px 6px;"><select class="rteP_${id}" data-p="${p}" data-r="${r.role}" data-was="${state}" style="${RTE_IN} font-size:12px;"><option value="default" ${state === 'default' ? 'selected' : ''}>default (${def})</option><option value="allow" ${state === 'allow' ? 'selected' : ''}>allow</option><option value="deny" ${state === 'deny' ? 'selected' : ''}>deny</option></select></td>`;
|
||
}).join('') + '</tr>').join('');
|
||
return `<div style="overflow:auto; margin-top:8px;"><table style="font-size:13px;"><tr><th></th>${head}</tr>${rows}</table></div>
|
||
<div style="margin-top:8px;"><button onclick="rteSavePerms(${id});" style="${RTE_BTN}">Save permissions</button> <button onclick="rteResetPerms(${id});" style="${RTE_BTN}">Back to the defaults</button></div>`;
|
||
}
|
||
function rteMoveHtml() {
|
||
return `<div style="background:var(--table-bg); border-radius:10px; padding:16px; margin-bottom:16px;">
|
||
<h3 style="margin-top:0;">Move or copy subrooms into another room</h3>
|
||
<p style="opacity:.75; margin:0 0 8px 0;">Tick "pick for moving" on the subrooms above (none ticked = all of them), choose the room they go to, and press the button.</p>
|
||
<div style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
|
||
<input id="rteTo" placeholder="Target room name or id" onkeydown="if(event.key==='Enter')rteFindTarget();" style="${RTE_IN} width:260px;" />
|
||
<button onclick="rteFindTarget();" style="${RTE_BTN}">Find</button>
|
||
<label><input type="radio" name="rteMode" value="move" checked /> move</label><label><input type="radio" name="rteMode" value="copy" /> copy</label>
|
||
<label><input type="checkbox" id="rteDelSrc" /> delete ^${dbEsc(_rte.room.name)} if it ends up empty</label>
|
||
</div>
|
||
<div id="rteToFound" style="margin:8px 0;"></div>
|
||
<div>Target: <b id="rteToLabel" style="opacity:.8;">none chosen</b> <input type="hidden" id="rteToId" /></div>
|
||
<div style="margin-top:10px;"><button onclick="rteMove();" style="${RTE_BTN}">Go</button></div></div>`;
|
||
}
|
||
function rteFindTarget() {
|
||
const q = document.getElementById('rteTo').value.trim(), out = document.getElementById('rteToFound');
|
||
if (!q) return;
|
||
fetch('/admin/api/roomtools/find?q=' + encodeURIComponent(q), { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
out.innerHTML = (d.rooms || []).map(r => `<div><a href="#" onclick="rtePickTarget(${Number(r.id)}, '${dbEsc(r.name).replace(/'/g, '')}'); return false;">^${dbEsc(r.name)}</a> <span style="opacity:.65;">room ${Number(r.id)}${r.creator ? ', by @' + dbEsc(r.creator) : ''}, ${Number(r.subrooms)} subroom(s)</span></div>`).join('') || '<div style="opacity:.7;">No room matches.</div>';
|
||
});
|
||
}
|
||
function rtePickTarget(id, name) { document.getElementById('rteToId').value = id; document.getElementById('rteToLabel').textContent = '^' + name + ' (room ' + id + ')'; document.getElementById('rteToFound').innerHTML = ''; }
|
||
function rteMove() {
|
||
const to = Number(document.getElementById('rteToId').value);
|
||
if (!to) { rteSay('Choose the target room first (Find, then click it).', true); return; }
|
||
const picked = [...document.querySelectorAll('.rteMoveSel:checked')].map(x => Number(x.value));
|
||
const copy = document.querySelector('input[name=rteMode]:checked').value === 'copy';
|
||
const what = picked.length ? picked.length + ' subroom(s)' : 'ALL ' + _rte.subRooms.length + ' subroom(s)';
|
||
if (!confirm(`${copy ? 'Copy' : 'Move'} ${what} of ^${_rte.room.name} into room ${to}?`)) return;
|
||
rtePost('/admin/api/roomtools/move_subrooms', { fromRoomId: _rte.room.id, toRoomId: to, subRoomIds: picked, mode: copy ? 'copy' : 'move', deleteEmptySource: document.getElementById('rteDelSrc').checked }).then(d => {
|
||
rteSay(d.message || (d.success ? 'Done.' : 'Failed.'), !d.success);
|
||
if (d.sourceDeleted) { _rte = null; document.getElementById('rteBody').innerHTML = ''; } else rteReload();
|
||
}).catch(() => rteSay('Request failed.', true));
|
||
}
|
||
function rteSaveRoom() {
|
||
const g = id => document.getElementById(id).checked;
|
||
rteForm('/admin/api/roomtools/room_settings', { roomId: _rte.room.id, screens: g('rteF_screens'), walkVR: g('rteF_walk'), teleportVR: g('rteF_tele'), vrLow: g('rteF_vrlow'), quest2: g('rteF_q2'), mobile: g('rteF_mob'), juniors: g('rteF_jr'), roomBlob: document.getElementById('rteMeta').value.trim() })
|
||
.then(d => { rteSay(d.message, !d.success); rteReload(); });
|
||
}
|
||
function rteSaveSub(id) {
|
||
const v = k => document.getElementById(k + '_' + id);
|
||
rteForm('/admin/api/roomtools/subroom_settings', { roomId: _rte.room.id, subRoomId: id, name: v('rteN').value, maxPlayers: v('rteM').value, accessibility: v('rteA').value, sandbox: v('rteS').checked, scene: v('rteC').value })
|
||
.then(d => { rteSay(d.message, !d.success); rteReload(); });
|
||
}
|
||
function rteSaveInfo(id, saveId) {
|
||
const v = k => document.getElementById(k + '_' + id);
|
||
rtePost('/admin/api/roomtools/save_info', { roomId: _rte.room.id, subRoomId: id, saveId, description: v('rteD').value, persistenceVersion: Number(v('rteP').value), omVersion: Number(v('rteO').value), ugcSubVersion: Number(v('rteU').value), file: v('rteFile').value.trim() })
|
||
.then(d => { rteSay(d.message, !d.success); rteReload(); });
|
||
}
|
||
function rteUpload(id) {
|
||
const f = document.getElementById('rteUp_' + id).files[0];
|
||
if (!f) { rteSay('Choose a .room file first.', true); return; }
|
||
const fd = new FormData(); fd.append('roomId', _rte.room.id); fd.append('subRoomId', id); fd.append('description', document.getElementById('rteUD_' + id).value); fd.append('file', f);
|
||
rteSay('Uploading ' + rteSize(f.size) + '...', false);
|
||
fetch('/admin/api/roomtools/save_file', { method: 'POST', credentials: 'same-origin', body: fd }).then(r => r.json()).then(d => { rteSay(d.message, !d.success); rteReload(); }).catch(() => rteSay('Upload failed.', true));
|
||
}
|
||
function rteBakedInfo() {
|
||
for (const sub of _rte.subRooms) fetch(`/admin/api/roomtools/baked?roomId=${_rte.room.id}&subRoomId=${sub.id}`, { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
const el = document.getElementById('rteB_' + sub.id); if (!el) return;
|
||
if (!d.assetId) { el.textContent = 'none'; return; }
|
||
if (!d.active) { el.textContent = d.off ? 'switched off - the game bakes this room itself' : 'none - the game bakes this room itself'; return; }
|
||
const pc = d.entries.filter(e => e.target === 0 && e.version === 3)[0], q = d.entries.filter(e => e.target === 2 && e.version === 3)[0];
|
||
el.textContent = 'PC ' + (pc ? (pc.file.exists ? rteSize(pc.file.size) : 'file missing') : 'none') + ', Quest ' + (q ? (q.file.exists ? rteSize(q.file.size) : 'file missing') : 'none');
|
||
});
|
||
}
|
||
function rteBaked(id, action) { rtePost('/admin/api/roomtools/baked', { roomId: _rte.room.id, subRoomId: id, action }).then(d => { rteSay(d.message, !d.success); rteBakedInfo(); }); }
|
||
function rteRestore(id, saveId) { rtePost('/admin/api/roomtools/restore_save', { roomId: _rte.room.id, subRoomId: id, saveId }).then(d => { rteSay(d.message, !d.success); rteReload(); }); }
|
||
function rteDeleteSub(id) {
|
||
if (!confirm('Delete this subroom (and its saves)? This cannot be undone.')) return;
|
||
rtePost('/admin/api/roomtools/delete_subroom', { roomId: _rte.room.id, subRoomId: id }).then(d => { rteSay(d.message, !d.success); rteReload(); });
|
||
}
|
||
function rteSavePerms(id) {
|
||
const changes = [];
|
||
document.querySelectorAll('.rteP_' + id).forEach(sel => {
|
||
if (sel.value === sel.dataset.was) return;
|
||
changes.push({ permission: sel.dataset.p, role: Number(sel.dataset.r), value: sel.value === 'default' ? null : (sel.value === 'deny' ? 'False' : 'True') });
|
||
});
|
||
if (!changes.length) { rteSay('Nothing changed.', false); return; }
|
||
rtePost('/admin/api/roomtools/permissions', { roomId: _rte.room.id, subRoomId: id, changes }).then(d => { rteSay(d.message, !d.success); rteReload(); });
|
||
}
|
||
function rteResetPerms(id) {
|
||
if (!confirm('Put every permission on this subroom back to the defaults?')) return;
|
||
rtePost('/admin/api/roomtools/permissions', { roomId: _rte.room.id, subRoomId: id, changes: [], resetAll: true }).then(d => { rteSay(d.message, !d.success); rteReload(); });
|
||
}
|
||
// ---- Archive tab (AdminArchiveController) ----
|
||
let _arc = null, _arcBusy = false;
|
||
function arcInit() { if (!_arc) arcLoad(false); }
|
||
function arcLoad(refresh) {
|
||
const sum = document.getElementById('arcSummary');
|
||
sum.textContent = 'Reading the archive...';
|
||
fetch('/admin/api/archive/rooms' + (refresh ? '?refresh=true' : ''), { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
if (!d.success) { sum.textContent = d.message || 'Could not read the archive.'; return; }
|
||
_arc = d; arcRender();
|
||
}).catch(() => { sum.textContent = 'Could not read the archive.'; });
|
||
}
|
||
function arcShown() {
|
||
if (!_arc) return [];
|
||
const q = document.getElementById('arcQ').value.trim().toLowerCase();
|
||
const hide = document.getElementById('arcHideDone').checked;
|
||
return _arc.rooms.filter(r => (!hide || !r.imported) && (!q || r.name.toLowerCase().includes(q)));
|
||
}
|
||
function arcRender() {
|
||
if (!_arc) return;
|
||
const list = arcShown();
|
||
const done = _arc.rooms.filter(r => r.imported).length;
|
||
document.getElementById('arcSummary').innerHTML = `<b>${list.length}</b> shown of ${_arc.count} rooms in the archive (${done} already imported).`;
|
||
document.getElementById('arcAll').disabled = _arcBusy || list.filter(r => !r.imported).length === 0;
|
||
document.getElementById('arcList').innerHTML = list.map(r => {
|
||
const imp = r.imported ? `<span style="color:#2ecc71; margin-left:8px;">✓ imported as ${dbEsc(r.imported.name)} (room ${dbEsc(r.imported.campusRoomId)})</span>` : '';
|
||
const btn = `<button onclick="arcImport('${dbEsc(r.name).replace(/'/g, '')}', this);" style="background:var(--card-hover-bg); color:white; border:none; cursor:pointer; border-radius:5px; padding:4px 12px; margin-left:10px;">${r.imported ? 'Import again' : 'Import'}</button>`;
|
||
return `<div style="margin-top:8px; display:flex; align-items:center; flex-wrap:wrap;"><span style="font-weight:bold;">${dbEsc(r.name)}</span>${imp}${btn}<a href="https://archive.splootybean.com/Rooms/${encodeURIComponent(r.name)}/" target="_blank" rel="noopener" style="margin-left:10px; font-size:13px;">open in the archive</a></div>`;
|
||
}).join('');
|
||
}
|
||
function arcDo(folder, name) {
|
||
return fetch('/admin/api/archive/import', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder, name: name || null, bundles: document.getElementById('arcBundles').value }) }).then(x => x.json());
|
||
}
|
||
function arcMark(folder, d) {
|
||
const r = _arc && _arc.rooms.find(x => x.name === folder);
|
||
if (r && d.success) r.imported = { campusRoomId: String(d.campusRoomId), name: d.name };
|
||
}
|
||
function arcImport(folder, btn) {
|
||
btn.disabled = true; btn.textContent = 'Importing...';
|
||
arcDo(folder).then(d => {
|
||
btn.disabled = false;
|
||
if (!d.success) { btn.textContent = 'Import'; alert('Import failed: ' + (d.message || 'error')); return; }
|
||
arcMark(folder, d); arcRender();
|
||
alert(`Imported as "${d.name}" (Campus room ${d.campusRoomId}) with ${(d.files || []).length} file(s).` + arcBundleNote(d));
|
||
}).catch(() => { btn.disabled = false; btn.textContent = 'Import'; alert('Import failed.'); });
|
||
}
|
||
function arcBundleNote(d) {
|
||
const b = d.bundles; if (!b) return '';
|
||
return `\nAsset bundles: ${b.downloaded} downloaded (${b.megabytes} MB), ${b.already} already here` + (b.problems && b.problems.length ? `\nProblems:\n${b.problems.join('\n')}` : '.');
|
||
}
|
||
function arcImportTyped() {
|
||
const v = document.getElementById('arcName').value.trim();
|
||
const out = document.getElementById('arcTypedOut');
|
||
if (!v) { out.innerHTML = '<div style="opacity:.7;">Type a room name or paste its archive address first.</div>'; return; }
|
||
out.textContent = 'Importing...';
|
||
arcDo(v).then(d => {
|
||
out.innerHTML = d.success ? `<span style="color:#2ecc71;">Imported as "${dbEsc(d.name)}" (Campus room ${dbEsc(d.campusRoomId)}) with ${(d.files || []).length} file(s).${d.bundles ? ' Asset bundles: ' + d.bundles.downloaded + ' downloaded (' + d.bundles.megabytes + ' MB), ' + d.bundles.already + ' already here.' : ''}${d.bundles && d.bundles.problems && d.bundles.problems.length ? '<br>' + d.bundles.problems.map(dbEsc).join('<br>') : ''}</span>`
|
||
: `<span style="color:#e74c3c;">${dbEsc(d.message || 'Import failed.')}</span>`;
|
||
if (d.success) { const m = v.match(/Rooms\/([^\/?#]+)/i); arcMark(m ? decodeURIComponent(m[1]) : v, d); arcRender(); }
|
||
}).catch(() => { out.innerHTML = '<span style="color:#e74c3c;">Import failed.</span>'; });
|
||
}
|
||
async function arcImportAll() {
|
||
const todo = arcShown().filter(r => !r.imported);
|
||
if (!todo.length || !confirm(`Import ${todo.length} room(s) from the archive? This downloads each one's files, including its asset bundles (${document.getElementById('arcBundles').value === 'none' ? 'none picked' : 'about 110 MB a room for PC + Quest'}), and can take a long while.`)) return;
|
||
_arcBusy = true; arcRender();
|
||
const bulk = document.getElementById('arcBulk');
|
||
let ok = 0; const bad = [];
|
||
for (let i = 0; i < todo.length; i++) {
|
||
bulk.textContent = `Importing ${i + 1} of ${todo.length}: ${todo[i].name} ...`;
|
||
try { const d = await arcDo(todo[i].name); if (d.success) { ok++; arcMark(todo[i].name, d); } else bad.push(todo[i].name + ' (' + (d.message || 'failed') + ')'); }
|
||
catch (e) { bad.push(todo[i].name + ' (request failed)'); }
|
||
arcRender();
|
||
}
|
||
_arcBusy = false; arcRender();
|
||
bulk.innerHTML = `Done: <b>${ok}</b> imported` + (bad.length ? `, <b>${bad.length}</b> failed:<div style="font-size:13px; margin-top:4px;">${bad.map(dbEsc).join('<br>')}</div>` : '.');
|
||
}
|
||
// ---- RecFlare CDN tab (AdminRecflareCdnController) ----
|
||
let _rfScanned = false, _rfFound = [];
|
||
function rfSize(n) { return n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(0) + ' KB' : n + ' bytes'; }
|
||
function rfInit() { if (!_rfScanned) rfScan(false); if (!_rfRooms) rfRooms(false); }
|
||
let _rfRooms = null, _rfShown = 60;
|
||
function rfRooms(refresh) {
|
||
const sum = document.getElementById('rfRoomsSummary');
|
||
sum.textContent = 'Reading every public room on RecFlare - the first time takes a minute or two...';
|
||
fetch('/admin/api/recflarecdn/rooms' + (refresh ? '?refresh=true' : ''), { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
if (!d.success) { sum.textContent = d.message || 'Could not read RecFlare\'s rooms.'; return; }
|
||
_rfRooms = d; _rfShown = 60; rfRoomsRender();
|
||
}).catch(() => { sum.textContent = 'Could not read RecFlare\'s rooms.'; });
|
||
}
|
||
function rfRoomsRender() {
|
||
if (!_rfRooms) return;
|
||
const q = document.getElementById('rfRoomQ').value.trim().toLowerCase();
|
||
const filesOnly = document.getElementById('rfRoomFilesOnly').checked;
|
||
const list = _rfRooms.rooms.filter(r => (!filesOnly || r.files.some(f => f.file)) &&
|
||
(!q || (r.name || '').toLowerCase().includes(q) || r.files.some(f => (f.file || '').toLowerCase().includes(q) || (f.subRoom || '').toLowerCase().includes(q))));
|
||
document.getElementById('rfRoomsSummary').innerHTML = `<b>${list.length}</b> of ${_rfRooms.count} public RecFlare rooms shown (${_rfRooms.files} files). <span style="opacity:.7;">(read ${new Date(_rfRooms.readAt).toLocaleString()})</span>`;
|
||
const box = document.getElementById('rfRoomsList');
|
||
box.innerHTML = list.slice(0, _rfShown).map(r => {
|
||
const imp = r.imported ? `<span style="color:#2ecc71; margin-left:8px;">✓ imported as ${dbEsc(r.imported.name)} (room ${dbEsc(r.imported.campusRoomId)})</span>` : '';
|
||
const btn = r.files.some(f => f.file) ? `<button onclick="rfImport(${Number(r.id)}, this);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px; padding:4px 12px; margin-left:10px;">${r.imported ? 'Import again' : 'Import'}</button>` : '';
|
||
const files = r.files.map(f => f.file
|
||
? `<div style="font-family:monospace; font-size:13px; margin:2px 0 2px 14px;">${dbEsc(f.subRoom)} - ${dbEsc(f.file)} <a href="/admin/api/recflarecdn/download?kind=room&name=${encodeURIComponent(f.file)}" style="margin-left:6px;">save a copy</a></div>`
|
||
: `<div style="font-family:monospace; font-size:13px; margin:2px 0 2px 14px; opacity:.55;">${dbEsc(f.subRoom)} - no save file (scene only)</div>`).join('');
|
||
return `<div style="margin-top:12px;"><div style="font-weight:bold; display:flex; align-items:center; flex-wrap:wrap;">${dbEsc(r.name)} <span style="opacity:.6; font-weight:normal; margin-left:6px;">(RecFlare room ${dbEsc(r.id)}${r.isRRO ? ', Rec Room original' : ''})</span>${btn}${imp}</div>${files}</div>`;
|
||
}).join('') + (list.length > _rfShown ? `<div style="margin-top:12px;"><button onclick="_rfShown += 100; rfRoomsRender();" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px;">Show more (${list.length - _rfShown} left)</button></div>` : '');
|
||
}
|
||
function rfImport(id, btn) {
|
||
const r = _rfRooms && _rfRooms.rooms.find(x => Number(x.id) === id);
|
||
const name = prompt('Import "' + (r ? r.name : id) + '" into Campus as a new room called:', r ? r.name : '');
|
||
if (name === null) return;
|
||
btn.disabled = true; btn.textContent = 'Importing...';
|
||
fetch('/admin/api/recflarecdn/import', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ roomId: id, name }) })
|
||
.then(x => x.json()).then(d => {
|
||
if (!d.success) { btn.disabled = false; btn.textContent = 'Import'; alert('Import failed: ' + (d.message || 'error')); return; }
|
||
if (r) r.imported = { campusRoomId: String(d.campusRoomId), name: d.name };
|
||
rfRoomsRender();
|
||
alert(`Imported as "${d.name}" (Campus room ${d.campusRoomId}) with ${(d.files || []).length} file(s)${d.maker ? ' - made by @' + d.maker + ' on RecFlare' : ''}.`);
|
||
}).catch(() => { btn.disabled = false; btn.textContent = 'Import'; alert('Import failed.'); });
|
||
}
|
||
function rfLookup() {
|
||
const kind = document.getElementById('rfKind').value;
|
||
const names = document.getElementById('rfName').value.split(/[\n,]+/).map(x => x.trim()).filter(Boolean).slice(0, 50);
|
||
const out = document.getElementById('rfLookupOut');
|
||
if (!names.length) { out.innerHTML = '<div style="opacity:.7;">Type a file name first.</div>'; return; }
|
||
out.innerHTML = '<div style="opacity:.7;">Checking...</div>';
|
||
Promise.all(names.map(n => fetch(`/admin/api/recflarecdn/lookup?kind=${encodeURIComponent(kind)}&name=${encodeURIComponent(n)}`, { credentials: 'same-origin' })
|
||
.then(r => r.json()).catch(() => ({ success: false, name: n, message: 'Could not check.' }))))
|
||
.then(list => {
|
||
out.innerHTML = list.map(d => {
|
||
if (!d.success) return `<div style="color:#e74c3c; font-family:monospace;">${dbEsc(d.name || '')} - ${dbEsc(d.message || 'error')}</div>`;
|
||
const here = d.local ? ' <span style="opacity:.7;">(this server already has it)</span>' : '';
|
||
if (!d.found) return `<div style="font-family:monospace;"><span style="color:#e74c3c;">✗ not on RecFlare</span> ${dbEsc(d.kind)}/${dbEsc(d.name)}${d.error ? ' - ' + dbEsc(d.error) : ''}${here}</div>`;
|
||
const dl = `<a href="/admin/api/recflarecdn/download?kind=${encodeURIComponent(d.kind)}&name=${encodeURIComponent(d.name)}" style="margin-left:8px;">save a copy</a>`;
|
||
const rs = d.canRestore && !d.local ? `<button onclick="rfRestore('${dbEsc(d.kind)}', ['${dbEsc(d.name)}'], this);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px; padding:4px 10px; margin-left:8px;">Restore into server</button>` : '';
|
||
return `<div style="font-family:monospace; margin:4px 0;"><span style="color:#2ecc71;">✓ on RecFlare</span> ${dbEsc(d.kind)}/${dbEsc(d.name)} - ${rfSize(d.size)}${here}${dl}${rs}</div>`;
|
||
}).join('');
|
||
});
|
||
}
|
||
function rfScan(refresh) {
|
||
_rfScanned = true;
|
||
const sum = document.getElementById('rfScanSummary'), rows = document.getElementById('rfScanRows');
|
||
sum.textContent = 'Scanning every room save this server references and checking RecFlare for the missing ones - this can take a minute...';
|
||
rows.innerHTML = '';
|
||
fetch('/admin/api/recflarecdn/scan' + (refresh ? '?refresh=true' : ''), { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
if (!d.success) { sum.textContent = d.message || 'The scan failed.'; return; }
|
||
_rfFound = d.rows.filter(x => x.found).map(x => x.blob);
|
||
_rfFound = [...new Set(_rfFound)];
|
||
document.getElementById('rfRestoreAll').disabled = !_rfFound.length;
|
||
sum.innerHTML = d.missing
|
||
? `<b>${d.missing}</b> room file(s) missing across <b>${d.rooms}</b> room(s); <b style="color:${d.onRecflare ? '#2ecc71' : 'inherit'};">${d.onRecflare}</b> of them are on RecFlare. <span style="opacity:.7;">(checked ${new Date(d.scannedAt).toLocaleString()})</span>`
|
||
: 'Every room save this server references is here. Nothing to restore.';
|
||
let last = null, html = '';
|
||
for (const x of d.rows) {
|
||
if (x.roomId !== last) {
|
||
last = x.roomId;
|
||
html += `<div style="margin-top:12px; font-weight:bold;">${dbEsc(x.room)} <span style="opacity:.6; font-weight:normal;">(room ${x.roomId})</span></div>`;
|
||
}
|
||
const state = x.found ? `<span style="color:#2ecc71;">✓ on RecFlare, ${rfSize(x.size)}</span> <button onclick="rfRestore('room', ['${dbEsc(x.blob)}'], this);" style="background:var(--card-hover-bg); color:white; padding:9px 14px; font-size:15px; border:none; cursor:pointer; border-radius:5px; padding:3px 9px;">Restore</button>`
|
||
: `<span style="color:#e74c3c;">✗ not on RecFlare</span>`;
|
||
html += `<div style="font-family:monospace; font-size:13px; margin:3px 0 3px 12px;">${x.what === 'save' ? dbEsc(x.subRoom || ('subroom ' + x.subRoomId)) : 'room data'} - ${dbEsc(x.blob)} - ${state}</div>`;
|
||
}
|
||
rows.innerHTML = html;
|
||
}).catch(() => { sum.textContent = 'The scan failed.'; });
|
||
}
|
||
function rfRestore(kind, names, btn) {
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Restoring...'; }
|
||
return fetch('/admin/api/recflarecdn/restore', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, names }) })
|
||
.then(r => r.json()).then(d => {
|
||
const res = (d && d.results) || [];
|
||
const ok = res.filter(x => x.ok).length;
|
||
if (btn) btn.textContent = ok === names.length ? 'Restored' : 'Failed: ' + ((res.find(x => !x.ok) || {}).message || 'error');
|
||
return res;
|
||
}).catch(() => { if (btn) btn.textContent = 'Failed'; });
|
||
}
|
||
function rfRestoreAll() {
|
||
if (!_rfFound.length || !confirm(`Download ${_rfFound.length} room file(s) from RecFlare into this server?`)) return;
|
||
const b = document.getElementById('rfRestoreAll');
|
||
rfRestore('room', _rfFound, b).then(() => rfScan(true));
|
||
}
|
||
|
||
function dbInit() {
|
||
if (_dbList.length) return;
|
||
fetch('/admin/api/db/list', { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
_dbList = (d && d.databases) || [];
|
||
const sel = document.getElementById('dbSelect');
|
||
sel.innerHTML = _dbList.map(x => `<option value="${dbEsc(x.name)}">${dbEsc(x.name)}${x.error ? ' (unavailable)' : ''}</option>`).join('');
|
||
sel.value = 'Players';
|
||
dbPickDb();
|
||
}).catch(() => dbMsg('Could not load the database list.', true));
|
||
}
|
||
function dbPickDb() {
|
||
const d = _dbList.find(x => x.name === document.getElementById('dbSelect').value);
|
||
const cs = document.getElementById('dbCol');
|
||
cs.innerHTML = ((d && d.collections) || []).map(c => `<option value="${dbEsc(c.name)}">${dbEsc(c.name)} (${c.count})</option>`).join('');
|
||
document.getElementById('dbAbout').textContent = d ? (d.about || '') + (d.size ? ' - ' + dbSize(d.size) : '') + (d.error ? ' - ' + d.error : '') : '';
|
||
dbBrowse(0);
|
||
}
|
||
function dbBrowse(skip) {
|
||
const { db, col } = dbCur();
|
||
const rows = document.getElementById('dbRows');
|
||
if (!db || !col) { rows.innerHTML = '<div style="opacity:.7;">No collections.</div>'; document.getElementById('dbPage').textContent = ''; return; }
|
||
_dbSkip = Math.max(0, skip || 0);
|
||
const q = document.getElementById('dbQ').value.trim(), where = document.getElementById('dbWhere').value.trim();
|
||
const url = `/admin/api/db/${encodeURIComponent(db)}/${encodeURIComponent(col)}?skip=${_dbSkip}&limit=50`
|
||
+ (where ? '&where=' + encodeURIComponent(where) : q ? '&q=' + encodeURIComponent(q) : '');
|
||
rows.innerHTML = '<div style="opacity:.7;">Loading...</div>';
|
||
fetch(url, { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
if (!d.success) { rows.innerHTML = `<div style="color:#e74c3c;">${dbEsc(d.message || 'Could not load.')}</div>`; document.getElementById('dbPage').textContent = ''; return; }
|
||
if (d.total > 0 && _dbSkip >= d.total) { dbBrowse(Math.max(0, d.total - 50)); return; }
|
||
document.getElementById('dbPage').textContent = d.total ? `${_dbSkip + 1}-${Math.min(_dbSkip + 50, d.total)} of ${d.total}${d.capped ? '+ (search stopped early)' : ''}` : '0 found';
|
||
rows.innerHTML = d.rows.length ? d.rows.map(r => `<button class="dbRow" data-id="${dbEsc(r.id)}" style="text-align:left; background:var(--card-bg, rgba(127,127,127,.08)); color:inherit; border:1px solid rgba(127,127,127,.25); border-radius:6px; padding:8px 10px; cursor:pointer; font-family:monospace; font-size:12px; overflow:hidden; word-break:break-all;"><b>${dbEsc(r.id)}</b><br><span style="opacity:.75;">${dbEsc(r.preview)}</span></button>`).join('')
|
||
: '<div style="opacity:.7;">Nothing matches.</div>';
|
||
rows.querySelectorAll('.dbRow').forEach(b => b.onclick = () => dbOpen(b.dataset.id));
|
||
}).catch(() => { rows.innerHTML = '<div style="color:#e74c3c;">Could not load.</div>'; });
|
||
}
|
||
function dbOpen(id) {
|
||
if (id == null) return;
|
||
if (_dbDirty && !confirm('Discard your unsaved changes?')) return;
|
||
const { db, col } = dbCur();
|
||
fetch(`/admin/api/db/${encodeURIComponent(db)}/${encodeURIComponent(col)}/doc?id=${encodeURIComponent(id)}`, { credentials: 'same-origin' })
|
||
.then(r => r.json()).then(d => {
|
||
if (!d.success) { dbMsg(d.message || 'Could not open it.', true); return; }
|
||
_dbDocId = d.id; _dbDirty = false;
|
||
document.getElementById('dbJson').value = d.json;
|
||
document.getElementById('dbDocTitle').textContent = `${db} / ${col} / ${d.id}`;
|
||
dbMsg('');
|
||
}).catch(() => dbMsg('Could not open it.', true));
|
||
}
|
||
function dbNew() {
|
||
if (_dbDirty && !confirm('Discard your unsaved changes?')) return;
|
||
const { db, col } = dbCur();
|
||
_dbDocId = null; _dbDirty = false;
|
||
document.getElementById('dbJson').value = '{\n \n}';
|
||
document.getElementById('dbDocTitle').textContent = `${db} / ${col} / new document`;
|
||
dbMsg('Type the new document. Leave out "_id" to have one made for you.');
|
||
}
|
||
function dbSave() {
|
||
const { db, col } = dbCur();
|
||
const json = document.getElementById('dbJson').value;
|
||
if (!json.trim()) return;
|
||
if (!confirm(_dbDocId ? `Save this document to ${db} / ${col}?` : `Add this new document to ${db} / ${col}?`)) return;
|
||
fetch(`/admin/api/db/${encodeURIComponent(db)}/${encodeURIComponent(col)}/save`, {
|
||
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: _dbDocId, json })
|
||
}).then(r => r.json()).then(d => {
|
||
dbMsg(d.message || (d.success ? 'Saved.' : 'Could not save.'), !d.success);
|
||
if (d.success) { _dbDirty = false; const was = _dbDocId; _dbDocId = d.id; dbOpen(d.id); if (!was) dbBrowse(_dbSkip); }
|
||
}).catch(() => dbMsg('Could not save.', true));
|
||
}
|
||
function dbDelete() {
|
||
if (!_dbDocId) { dbMsg('Open a document first.', true); return; }
|
||
const { db, col } = dbCur();
|
||
if (!confirm(`Delete ${_dbDocId} from ${db} / ${col}? It is kept in the audit file, but this can't be undone from here.`)) return;
|
||
fetch(`/admin/api/db/${encodeURIComponent(db)}/${encodeURIComponent(col)}/delete`, {
|
||
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: _dbDocId })
|
||
}).then(r => r.json()).then(d => {
|
||
dbMsg(d.message || (d.success ? 'Deleted.' : 'Could not delete.'), !d.success);
|
||
if (d.success) { _dbDocId = null; _dbDirty = false; document.getElementById('dbJson').value = ''; document.getElementById('dbDocTitle').textContent = 'No document open'; dbBrowse(_dbSkip); }
|
||
}).catch(() => dbMsg('Could not delete.', true));
|
||
}
|
||
document.addEventListener('input', e => { if (e.target && e.target.id === 'dbJson') _dbDirty = true; });
|
||
|
||
// ---- Deep link from the website's quick-admin popup ----------------------------------------
|
||
// /admin/home/v4?player=<id> opens Edit Player loaded with them plus their profile popup (ban,
|
||
// kick, mic ban...); ?room=<id> opens the Rooms tab and that room's editor. The website also
|
||
// leaves the target in localStorage ("campus-admin-open", 10 minutes) because the panel's
|
||
// login redirect drops the query string - so it still opens after signing in.
|
||
(function adminDeepLink() {
|
||
const p = new URLSearchParams(location.search);
|
||
let kind = null, id = null;
|
||
if (p.get('player')) { kind = 'player'; id = p.get('player'); }
|
||
else if (p.get('room')) { kind = 'room'; id = p.get('room'); }
|
||
if (!kind) {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem('campus-admin-open') || 'null');
|
||
if (s && Date.now() - s.at < 600000) { kind = s.kind; id = String(s.id); }
|
||
} catch (e) { }
|
||
}
|
||
try { localStorage.removeItem('campus-admin-open'); } catch (e) { }
|
||
if (!kind || !/^\d+$/.test(String(id))) return;
|
||
if (location.search) history.replaceState(null, '', location.pathname);
|
||
const go = () => setTimeout(() => {
|
||
if (kind === 'player') {
|
||
const tab = document.querySelector('.tab[data-tab="EditPlayer"]');
|
||
if (tab) tab.click();
|
||
const inp = document.getElementById('editPlayerId');
|
||
if (inp) { inp.value = id; editLoad(); }
|
||
viewPlayer(id, false);
|
||
} else {
|
||
const tab = document.querySelector('.tab[data-tab="Rooms"]');
|
||
if (tab) tab.click();
|
||
if (typeof openRoomEditor === 'function') openRoomEditor(id);
|
||
}
|
||
}, 400);
|
||
if (document.readyState === 'complete') go(); else window.addEventListener('load', go);
|
||
})();
|
||
</script>
|
||
</body>
|
||
|
||
</html> |