5166 lines
297 KiB
HTML
5166 lines
297 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 href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* Theme Variables */
|
||
body.red {
|
||
--navbar-bg: linear-gradient(135deg, rgba(180, 14, 14, 0.95), rgba(140, 10, 10, 0.95));
|
||
--sidebar-bg: rgba(97, 14, 14, 0.9);
|
||
--popup-bg: rgba(180, 14, 14, 0.95);
|
||
--card-bg: rgba(180, 14, 14, 0.9);
|
||
--card-hover-bg: rgba(107, 10, 10, 0.95);
|
||
--table-bg: rgba(180, 14, 14, 0.85);
|
||
--btn-confirm-bg: #28a745;
|
||
--btn-cancel-bg: #dc3545;
|
||
--btn-close-bg: #0078d7;
|
||
--accent-color: #ff6b6b;
|
||
}
|
||
|
||
body.green {
|
||
--navbar-bg: linear-gradient(135deg, rgba(0, 150, 12, 1), rgba(0, 120, 10, 1));
|
||
--sidebar-bg: rgba(0, 150, 12, 0.4);
|
||
--popup-bg: rgba(0, 150, 12, 1);
|
||
--card-bg: rgba(0, 150, 12, 0.9);
|
||
--card-hover-bg: rgba(0, 110, 10, 1);
|
||
--table-bg: rgba(0, 150, 12, 0.9);
|
||
--btn-confirm-bg: rgba(0, 120, 10, 1);
|
||
--btn-cancel-bg: #125210;
|
||
--btn-close-bg: #006400;
|
||
--accent-color: #4ade80;
|
||
}
|
||
|
||
body.purple {
|
||
--navbar-bg: linear-gradient(135deg, rgba(87, 27, 148, 0.95), rgba(63, 20, 110, 0.95));
|
||
--sidebar-bg: rgba(87, 27, 148, 0.4);
|
||
--popup-bg: rgba(87, 27, 148, 0.95);
|
||
--card-bg: rgba(87, 27, 148, 0.9);
|
||
--card-hover-bg: rgba(63, 20, 110, 0.95);
|
||
--table-bg: rgba(87, 27, 148, 0.85);
|
||
--btn-confirm-bg: #7e57c2;
|
||
--btn-cancel-bg: #ab47bc;
|
||
--btn-close-bg: #512da8;
|
||
--accent-color: #a78bfa;
|
||
}
|
||
|
||
body.dark {
|
||
--navbar-bg: linear-gradient(135deg, rgba(30, 30, 30, 0.98), rgba(20, 20, 20, 0.98));
|
||
--sidebar-bg: rgba(35, 35, 35, 0.95);
|
||
--popup-bg: rgba(40, 40, 40, 0.98);
|
||
--card-bg: rgba(35, 35, 35, 0.95);
|
||
--card-hover-bg: rgba(55, 55, 55, 0.95);
|
||
--table-bg: rgba(40, 40, 40, 0.95);
|
||
--btn-confirm-bg: #2a2a2a;
|
||
--btn-cancel-bg: #660000;
|
||
--btn-close-bg: #333333;
|
||
--accent-color: #60a5fa;
|
||
}
|
||
|
||
body.blue {
|
||
--navbar-bg: linear-gradient(135deg, rgba(30, 70, 180, 0.98), rgba(20, 50, 130, 0.98));
|
||
--sidebar-bg: rgba(30, 70, 180, 0.4);
|
||
--popup-bg: rgba(30, 70, 180, 0.98);
|
||
--card-bg: rgba(30, 70, 180, 0.9);
|
||
--card-hover-bg: rgba(20, 50, 130, 0.95);
|
||
--table-bg: rgba(30, 70, 180, 0.9);
|
||
--btn-confirm-bg: #1e46b4;
|
||
--btn-cancel-bg: #123070;
|
||
--btn-close-bg: #0b2448;
|
||
--accent-color: #60a5fa;
|
||
}
|
||
|
||
body.pink {
|
||
--navbar-bg: linear-gradient(135deg, rgba(200, 50, 120, 0.95), rgba(150, 30, 90, 0.95));
|
||
--sidebar-bg: rgba(200, 100, 130, 0.4);
|
||
--popup-bg: rgba(200, 50, 120, 0.95);
|
||
--card-bg: rgba(200, 50, 120, 0.9);
|
||
--card-hover-bg: rgba(150, 30, 90, 0.95);
|
||
--table-bg: rgba(200, 50, 120, 0.85);
|
||
--btn-confirm-bg: #c83278;
|
||
--btn-cancel-bg: #a02060;
|
||
--btn-close-bg: #801850;
|
||
--accent-color: #f472b6;
|
||
}
|
||
|
||
body.orange {
|
||
--navbar-bg: linear-gradient(135deg, rgba(255, 115, 0, 0.95), rgba(200, 90, 0, 0.95));
|
||
--sidebar-bg: rgba(255, 160, 60, 0.4);
|
||
--popup-bg: rgba(255, 115, 0, 0.95);
|
||
--card-bg: rgba(255, 115, 0, 0.9);
|
||
--card-hover-bg: rgba(200, 90, 0, 0.95);
|
||
--table-bg: rgba(255, 115, 0, 0.85);
|
||
--btn-confirm-bg: #ff9800;
|
||
--btn-cancel-bg: #e65100;
|
||
--btn-close-bg: #bf360c;
|
||
--accent-color: #fb923c;
|
||
}
|
||
|
||
body.yellow {
|
||
--navbar-bg: linear-gradient(135deg, rgba(204, 153, 0, 0.95), rgba(160, 120, 0, 0.95));
|
||
--sidebar-bg: rgba(204, 153, 0, 0.4);
|
||
--popup-bg: rgba(204, 153, 0, 0.95);
|
||
--card-bg: rgba(204, 153, 0, 0.9);
|
||
--card-hover-bg: rgba(160, 120, 0, 0.95);
|
||
--table-bg: rgba(204, 153, 0, 0.85);
|
||
--btn-confirm-bg: #cc9900;
|
||
--btn-cancel-bg: #b8860b;
|
||
--btn-close-bg: #8b7500;
|
||
--accent-color: #fbbf24;
|
||
}
|
||
|
||
/* Campus branding: matches the login page + the UIGradient recolor patch. Purple/
|
||
magenta/orange/gold sunset ramp - same 4 stops sampled from the admin panel's
|
||
Background.png. ALL colour values are solid rgba() (never gradients!) because
|
||
the page uses BOTH `background:` and `background-color:` against these vars and
|
||
`background-color:` silently drops gradients, which would make cards, navbar
|
||
buttons and the user chip vanish. Gradients live in the body background only. */
|
||
body.campus {
|
||
--navbar-bg: rgba(46, 20, 60, 0.72); /* deep ink for top bar */
|
||
--sidebar-bg: rgba(46, 20, 60, 0.58);
|
||
--popup-bg: rgba(30, 14, 46, 0.95);
|
||
--card-bg: rgba(121, 27, 141, 0.55); /* purple, translucent */
|
||
--card-hover-bg: rgba(191, 41, 102, 0.75); /* magenta, opaque-ish */
|
||
--table-bg: rgba(30, 14, 46, 0.72);
|
||
--btn-confirm-bg: #F95D24; /* Campus orange */
|
||
--btn-cancel-bg: #791B8D; /* Campus purple */
|
||
--btn-close-bg: #4a1361;
|
||
--accent-color: #FA9912; /* Campus gold */
|
||
/* The actual sunset painted directly on body (fixed so scrolling content
|
||
floats above it). Kept HERE, not on the vars, so it doesn't collide with
|
||
any element that does `background: var(--something)`. */
|
||
background: linear-gradient(135deg, #791B8D 0%, #BF2966 40%, #F95D24 75%, #FA9912 100%) fixed;
|
||
}
|
||
/* AnimatedIcon watermark drifting behind every admin page. z-index: -1 pushes it
|
||
behind ALL sibling content without needing any rule on children (which would
|
||
risk breaking layouts that rely on default `position: static`). */
|
||
body.campus::before {
|
||
content: "";
|
||
position: fixed;
|
||
inset: -40vmax;
|
||
background-image: url('/admin-assets/AnimatedIcon.png');
|
||
background-repeat: repeat;
|
||
background-size: 150px 150px;
|
||
transform: rotate(-8deg);
|
||
opacity: 0.08;
|
||
pointer-events: none;
|
||
z-index: -1;
|
||
animation: campusDrift 60s linear infinite;
|
||
}
|
||
@keyframes campusDrift {
|
||
from { background-position: 0 0; }
|
||
to { background-position: 300px 300px; }
|
||
}
|
||
|
||
/* Base Styles */
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
--navbar-bg: linear-gradient(135deg, rgba(180, 14, 14, 0.95), rgba(140, 10, 10, 0.95));
|
||
--sidebar-bg: rgba(97, 14, 14, 0.9);
|
||
--popup-bg: rgba(180, 14, 14, 0.95);
|
||
--card-bg: rgba(180, 14, 14, 0.9);
|
||
--card-hover-bg: rgba(107, 10, 10, 0.95);
|
||
--table-bg: rgba(180, 14, 14, 0.85);
|
||
--btn-confirm-bg: #28a745;
|
||
--btn-cancel-bg: #dc3545;
|
||
--btn-close-bg: #0078d7;
|
||
--accent-color: #ff6b6b;
|
||
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
background: #000000;
|
||
color: white;
|
||
min-height: 100vh;
|
||
background-size: cover;
|
||
background-position: center center;
|
||
}
|
||
|
||
/* Background Layer */
|
||
#background-blur-layer {
|
||
position: fixed;
|
||
top: -10px;
|
||
left: -10px;
|
||
width: calc(100vw + 20px);
|
||
height: calc(100vh + 20px);
|
||
background-size: cover;
|
||
background-position: center;
|
||
background-attachment: fixed;
|
||
filter: blur(2px);
|
||
z-index: -1;
|
||
transition: background-image 0.3s ease;
|
||
}
|
||
|
||
/* Space Background */
|
||
.space-background {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: -100;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #000;
|
||
overflow: hidden;
|
||
visibility: hidden;
|
||
}
|
||
|
||
.star {
|
||
position: absolute;
|
||
width: 3px;
|
||
height: 3px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
animation: twinkle 2s infinite ease-in-out;
|
||
}
|
||
|
||
.star:nth-child(odd) {
|
||
animation-duration: 4s;
|
||
}
|
||
|
||
@keyframes twinkle {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
50% {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* Navbar */
|
||
.navbar {
|
||
display: flex;
|
||
align-items: center;
|
||
background: var(--navbar-bg);
|
||
position: fixed;
|
||
width: 100%;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 1000;
|
||
gap: 20px;
|
||
padding: 12px 24px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.navbar h1 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.navbar button {
|
||
background-color: var(--card-hover-bg);
|
||
color: white;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 16px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
font-weight: 500;
|
||
transition: all 0.2s ease;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.navbar button:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.navbar button.active {
|
||
background: var(--accent-color);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
#you-button {
|
||
background-color: var(--card-hover-bg);
|
||
border-radius: 8px;
|
||
padding: 8px 16px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
#you-button:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
/* Content Area */
|
||
.content {
|
||
display: none;
|
||
opacity: 0;
|
||
text-align: center;
|
||
transition: opacity 0.3s ease-in-out;
|
||
margin-left: 40px;
|
||
margin-right: 40px;
|
||
padding: 100px 20px 40px;
|
||
}
|
||
|
||
.content.active {
|
||
display: block;
|
||
opacity: 1;
|
||
}
|
||
|
||
.content h2 {
|
||
font-size: 32px;
|
||
font-weight: 600;
|
||
margin-bottom: 24px;
|
||
color: var(--accent-color);
|
||
}
|
||
|
||
.content p {
|
||
font-size: 16px;
|
||
color: #ddd;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* Dashboard Cards */
|
||
.dashboard {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 24px;
|
||
margin-bottom: 40px;
|
||
justify-content: center;
|
||
}
|
||
|
||
|
||
.card,
|
||
.card_2 {
|
||
background: var(--card-bg);
|
||
padding: 24px;
|
||
border-radius: 16px;
|
||
text-align: center;
|
||
transition: all 0.3s ease;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.card:hover,
|
||
.card_2:hover {
|
||
background: var(--card-hover-bg);
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.card h3 {
|
||
margin: 0 0 12px 0;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: var(--accent-color);
|
||
}
|
||
|
||
.card p {
|
||
margin: 8px 0;
|
||
font-size: 18px;
|
||
color: #fff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.card button {
|
||
margin-top: 16px;
|
||
background: var(--btn-close-bg);
|
||
color: white;
|
||
border: none;
|
||
padding: 10px 20px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.card button:hover {
|
||
transform: scale(1.05);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
/* Tables */
|
||
.table {
|
||
color: rgba(255, 255, 255, 0.95);
|
||
background: var(--table-bg);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.table thead {
|
||
background: rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.table th {
|
||
padding: 16px;
|
||
font-weight: 600;
|
||
border: none;
|
||
}
|
||
|
||
.table td {
|
||
padding: 12px 16px;
|
||
vertical-align: middle;
|
||
border: none;
|
||
}
|
||
|
||
.table-striped tbody tr:nth-of-type(odd) {
|
||
background-color: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.table button {
|
||
margin: 4px;
|
||
padding: 8px 16px;
|
||
border-radius: 6px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.table button:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
/* Profile Images */
|
||
.profile-img,
|
||
.profile-img2 {
|
||
border-radius: 12px;
|
||
object-fit: cover;
|
||
transition: transform 0.3s ease;
|
||
image-rendering: -webkit-optimize-contrast;
|
||
image-rendering: crisp-edges;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.profile-img {
|
||
width: 80px;
|
||
height: 80px;
|
||
}
|
||
|
||
.profile-img2 {
|
||
width: 150px;
|
||
height: 150px;
|
||
}
|
||
|
||
.profile-img:hover,
|
||
.profile-img2:hover {
|
||
transform: scale(1.05);
|
||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
/* Popups */
|
||
.popup-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
visibility: hidden;
|
||
opacity: 0;
|
||
transition: all 0.3s ease;
|
||
backdrop-filter: blur(8px);
|
||
z-index: 2000;
|
||
}
|
||
|
||
.popup-overlay.active {
|
||
visibility: visible;
|
||
opacity: 1;
|
||
}
|
||
|
||
.popup {
|
||
background: var(--popup-bg);
|
||
border-radius: 16px;
|
||
padding: 32px;
|
||
max-width: 400px;
|
||
width: 90%;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||
text-align: center;
|
||
animation: slideIn 0.3s ease;
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from {
|
||
transform: translateY(-20px);
|
||
opacity: 0;
|
||
}
|
||
|
||
to {
|
||
transform: translateY(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.popup img {
|
||
max-width: 120px;
|
||
margin-bottom: 20px;
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.popup h2 {
|
||
margin: 0 0 16px;
|
||
font-size: 24px;
|
||
color: var(--accent-color);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.popup p {
|
||
font-size: 16px;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
margin: 0 0 20px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* Player Popup */
|
||
.player-popup {
|
||
background: var(--popup-bg);
|
||
max-width: 600px;
|
||
width: 550px;
|
||
border-radius: 16px;
|
||
padding: 32px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
flex-direction: column;
|
||
animation: slideIn 0.3s ease;
|
||
}
|
||
|
||
.popup-header {
|
||
display: flex !important;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
#player-popup-icon {
|
||
display: block;
|
||
max-width: 40%;
|
||
height: auto;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
#player-popup-title {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: var(--accent-color);
|
||
margin: 0;
|
||
}
|
||
|
||
#player-popup-text {
|
||
font-size: 14px;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
margin: 4px 0 0;
|
||
}
|
||
|
||
#player-popup-banstatus {
|
||
margin: 8px 0 0;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
#player-popup-bio {
|
||
margin-top: 16px;
|
||
padding: 16px;
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-radius: 12px;
|
||
line-height: 1.6;
|
||
white-space: pre-line;
|
||
}
|
||
|
||
#popup-buttons {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
margin-top: 24px;
|
||
}
|
||
|
||
/* Buttons */
|
||
.confirm-btn,
|
||
.cancel-btn,
|
||
.close-btn {
|
||
background: #300c77;
|
||
color: #fff;
|
||
border: none;
|
||
padding: 12px 24px;
|
||
font-size: 16px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.confirm-btn {
|
||
background: var(--btn-confirm-bg);
|
||
}
|
||
|
||
.confirm-btn:hover {
|
||
background: #218838;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.cancel-btn {
|
||
background: var(--btn-cancel-bg);
|
||
}
|
||
|
||
.cancel-btn:hover {
|
||
opacity: 0.9;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.close-btn {
|
||
background: var(--btn-close-bg);
|
||
}
|
||
|
||
.close-btn:hover {
|
||
background: #005a9e;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
/* Forms */
|
||
.themed-form-container {
|
||
background-color: var(--table-bg);
|
||
color: white;
|
||
padding: 20px;
|
||
border-radius: 12px;
|
||
margin-bottom: 20px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.themed-form-container form {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.themed-form-container input,
|
||
.themed-form-container select {
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
background: rgba(0, 0, 0, 0.3);
|
||
color: white;
|
||
font-size: 14px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.themed-form-container input:focus,
|
||
.themed-form-container select:focus {
|
||
outline: none;
|
||
border-color: var(--accent-color);
|
||
background: rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.themed-form-container button {
|
||
padding: 10px 20px;
|
||
border-radius: 8px;
|
||
font-weight: 500;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.themed-form-container button:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.themed-form-container label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.input-block {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
font-size: 16px;
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
color: white;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.input-block:focus {
|
||
outline: none;
|
||
border-color: var(--accent-color);
|
||
background: rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
/* Dropdown Container */
|
||
.dropdown-container {
|
||
margin: 20px 0;
|
||
padding: 20px;
|
||
background-color: rgba(51, 51, 51, 0.8);
|
||
border-radius: 12px;
|
||
width: 280px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.dropdown-container label {
|
||
color: #fff;
|
||
font-size: 16px;
|
||
display: block;
|
||
margin-bottom: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
#background-selector,
|
||
#theme-selector {
|
||
width: 100%;
|
||
padding: 12px 14px;
|
||
font-size: 14px;
|
||
color: #fff;
|
||
background-color: rgba(17, 16, 16, 0.9);
|
||
border: 1px solid rgba(119, 119, 119, 0.5);
|
||
border-radius: 8px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
#background-selector:focus,
|
||
#theme-selector:focus {
|
||
border-color: var(--accent-color);
|
||
box-shadow: 0 0 8px rgba(0, 188, 212, 0.5);
|
||
}
|
||
|
||
#background-selector option,
|
||
#theme-selector option {
|
||
background-color: #222;
|
||
color: #fff;
|
||
padding: 10px;
|
||
}
|
||
|
||
/* Toggle Switch */
|
||
.switch {
|
||
position: relative;
|
||
display: inline-block;
|
||
width: 60px;
|
||
height: 34px;
|
||
}
|
||
|
||
.switch input {
|
||
opacity: 0;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.slider {
|
||
position: absolute;
|
||
cursor: pointer;
|
||
top: 9px;
|
||
left: 12px;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: #fff;
|
||
transition: 0.4s;
|
||
height: 16px;
|
||
width: 42px;
|
||
}
|
||
|
||
.slider:before {
|
||
position: absolute;
|
||
content: "";
|
||
height: 26px;
|
||
width: 26px;
|
||
left: -4px;
|
||
bottom: -4px;
|
||
background-color: rgb(241, 110, 70);
|
||
transition: 0.4s;
|
||
}
|
||
|
||
input:checked+.slider {
|
||
background-color: rgb(135, 69, 49);
|
||
}
|
||
|
||
input+.slider {
|
||
background-color: #616161;
|
||
}
|
||
|
||
input:checked+.slider:before {
|
||
background-color: rgb(241, 110, 70);
|
||
transform: translateX(26px);
|
||
}
|
||
|
||
input+.slider:before {
|
||
background-color: rgb(224, 224, 224);
|
||
}
|
||
|
||
.slider.round {
|
||
border-radius: 34px;
|
||
}
|
||
|
||
.slider.round:before {
|
||
border-radius: 50%;
|
||
}
|
||
|
||
/* Spinning Animation */
|
||
.spinning {
|
||
animation: spin 3s linear infinite;
|
||
width: 120px;
|
||
height: 100px;
|
||
object-fit: contain;
|
||
margin: 0 auto;
|
||
display: block;
|
||
}
|
||
|
||
@keyframes spin {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.ban-status {
|
||
padding: 10px 14px;
|
||
border-radius: 10px;
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
/* NOT BANNED */
|
||
.ban-status.ok {
|
||
background: rgba(40, 167, 69, 0.15);
|
||
border: 1px solid rgba(40, 167, 69, 0.4);
|
||
color: #9be7b2;
|
||
}
|
||
|
||
/* BANNED */
|
||
.ban-status.bad {
|
||
background: rgba(220, 53, 69, 0.18);
|
||
border: 1px solid rgba(220, 53, 69, 0.45);
|
||
color: #ff9aa2;
|
||
}
|
||
|
||
.ban-status i {
|
||
font-size: 16px;
|
||
}
|
||
|
||
|
||
/* Mobile Support */
|
||
body.mobile-support {
|
||
font-size: 14px;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
body.mobile-support .navbar {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 10px;
|
||
}
|
||
|
||
body.mobile-support .tabs {
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
body.mobile-support .card,
|
||
body.mobile-support .card_2 {
|
||
width: 90% !important;
|
||
max-width: 100%;
|
||
font-size: 14px;
|
||
margin: 10px auto;
|
||
}
|
||
|
||
body.mobile-support .content {
|
||
margin-left: 0;
|
||
padding: 20px;
|
||
}
|
||
|
||
body.mobile-support .popup {
|
||
max-width: 95%;
|
||
padding: 15px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
body.mobile-support .dashboard {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
/* Utility Classes */
|
||
.log-item {
|
||
color: #ffffff;
|
||
max-width: 100%;
|
||
width: 100%;
|
||
border-radius: 8px;
|
||
background-color: var(--table-bg);
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
border-bottom: 1px solid rgba(204, 204, 204, 0.2);
|
||
margin-left: 0;
|
||
padding-left: 12px;
|
||
transition: background 0.2s ease;
|
||
}
|
||
|
||
.list-item:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.list-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
/* Home Image */
|
||
#home-image {
|
||
margin-bottom: 24px;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
/* Responsive Images */
|
||
img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
|
||
.spacer {
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 16px;
|
||
max-width: 900px;
|
||
margin: 0 auto 48px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border-radius: 12px;
|
||
padding: 16px 18px;
|
||
text-align: center;
|
||
backdrop-filter: blur(6px);
|
||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-3px);
|
||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
.stat-label {
|
||
display: block;
|
||
font-size: 0.8rem;
|
||
color: #aaa;
|
||
margin-bottom: 6px;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 1.1rem;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
}
|
||
|
||
.player-platforms {
|
||
margin-top: 16px;
|
||
padding: 12px;
|
||
background: rgba(0, 0, 0, 0.25);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.popup-section-title {
|
||
margin: 0 0 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--accent-color);
|
||
}
|
||
|
||
.platform-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
border-radius: 999px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
text-decoration: none;
|
||
margin-right: 8px;
|
||
margin-bottom: 6px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.platform-badge.steam {
|
||
background: linear-gradient(135deg, #171a21, #2a475e);
|
||
color: #fff;
|
||
}
|
||
|
||
.platform-badge.muted {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: #aaa;
|
||
}
|
||
|
||
.platform-badge.unknown {
|
||
background: #555;
|
||
color: #fff;
|
||
}
|
||
|
||
.ban-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
/* Labels */
|
||
.ban-form label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #ff6b6b;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
/* Inputs & Select */
|
||
.ban-form input,
|
||
.ban-form select {
|
||
background: rgba(0, 0, 0, 0.35);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
color: white;
|
||
padding: 10px 12px;
|
||
font-size: 14px;
|
||
border-radius: 8px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
/* Focus */
|
||
.ban-form input:focus,
|
||
.ban-form select:focus {
|
||
outline: none;
|
||
border-color: #ff6b6b;
|
||
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.25);
|
||
background: rgba(0, 0, 0, 0.45);
|
||
}
|
||
|
||
/* Duration Grid */
|
||
.ban-duration {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 8px;
|
||
}
|
||
|
||
.ban-duration input {
|
||
text-align: center;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Remove ugly number arrows */
|
||
.ban-duration input::-webkit-outer-spin-button,
|
||
.ban-duration input::-webkit-inner-spin-button {
|
||
-webkit-appearance: none;
|
||
margin: 0;
|
||
}
|
||
|
||
.ban-duration input {
|
||
-moz-appearance: textfield;
|
||
}
|
||
|
||
/* Category Select */
|
||
#banCategory {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ban-form::after {
|
||
font-size: 12px;
|
||
color: rgba(255, 107, 107, 0.75);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.ban-time {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.ban-time input,
|
||
.ban-time select {
|
||
flex: 1;
|
||
padding: 10px;
|
||
font-size: 14px;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(255,255,255,0.15);
|
||
background: rgba(255,255,255,0.04);
|
||
color: #fff;
|
||
}
|
||
|
||
.ban-preview {
|
||
margin-top: 6px;
|
||
font-size: 13px;
|
||
color: #9aa0a6;
|
||
}
|
||
|
||
|
||
.ban-appeal-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
background: rgba(0, 0, 0, 0.25);
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
/* Appeal text */
|
||
.appeal-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
font-size: 13px;
|
||
color: #fff;
|
||
}
|
||
|
||
.appeal-text span {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
}
|
||
|
||
/* Reuse your existing switch styling, but tighten it */
|
||
.ban-appeal-toggle .switch {
|
||
width: 52px;
|
||
height: 28px;
|
||
}
|
||
|
||
.ban-appeal-toggle .slider {
|
||
height: 18px;
|
||
width: 40px;
|
||
}
|
||
|
||
.ban-appeal-toggle .slider:before {
|
||
height: 22px;
|
||
width: 22px;
|
||
}
|
||
|
||
/* Role toggle buttons in player rows. Inactive = neutral gray; active = Campus purple. */
|
||
.btn-role {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: #d9c9ff;
|
||
border: 1px solid rgba(155, 92, 255, 0.35);
|
||
padding: 4px 10px;
|
||
margin-left: 4px;
|
||
border-radius: 6px;
|
||
font-size: 0.85rem;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||
}
|
||
.btn-role:hover { background: rgba(155, 92, 255, 0.25); }
|
||
.btn-role.role-on { background: #9b5cff; color: #fff; border-color: #b784ff; box-shadow: 0 0 6px rgba(155, 92, 255, 0.5); }
|
||
.btn-role.role-on:hover { background: #b784ff; }
|
||
|
||
</style>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||
Campus website skin: reskins the admin panel to match the main site's design
|
||
(Inter, flat off-white/dark surfaces, purple + warm gradient accents) and adds
|
||
full mobile responsiveness. Loaded LAST so it wins the cascade over the legacy
|
||
theme CSS above; leaves all structure/markup/JS untouched.
|
||
═══════════════════════════════════════════════════════════════════════════ -->
|
||
<style id="campus-website-skin">
|
||
:root{
|
||
--primary:#7c3aed; --primary-dark:#6d28d9; --primary-soft:#f0ebfe;
|
||
--page:#f6f7f9; --bg:#ffffff; --card:#ffffff; --panel:#f3f4f6;
|
||
--line:#e6e7eb; --text:#111827; --muted:#6b7280;
|
||
--ok:#15803d; --danger:#dc2626; --warn:#b45309;
|
||
--shadow:0 1px 2px rgba(16,24,40,.05), 0 1px 3px rgba(16,24,40,.06);
|
||
}
|
||
@media (prefers-color-scheme: dark){
|
||
:root{
|
||
--primary:#a78bfa; --primary-dark:#8b5cf6; --primary-soft:rgba(167,139,250,.14);
|
||
--page:#0f1117; --bg:#151823; --card:#171a24; --panel:#1e2230;
|
||
--line:#2a2f3d; --text:#e7e9ee; --muted:#98a1b3;
|
||
--ok:#4ade80; --danger:#f87171; --warn:#fbbf24;
|
||
--shadow:0 1px 2px rgba(0,0,0,.4);
|
||
}
|
||
}
|
||
|
||
/* base — kill the legacy sunset gradient + watermark + blur/space layers */
|
||
body, body.campus, body.red, body.green, body.purple, body.dark{
|
||
background:var(--page) !important; color:var(--text) !important;
|
||
font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif !important;
|
||
letter-spacing:-.006em; font-size:14px;
|
||
}
|
||
body.campus::before, #background-blur-layer, .space-background{ display:none !important; }
|
||
#home-image{ display:none !important; } /* placeholder hero avatar — clutter */
|
||
* { -webkit-font-smoothing:antialiased; }
|
||
|
||
/* ── top bar: brand · tabs · you ── */
|
||
.navbar{
|
||
background:var(--card) !important; border-bottom:1px solid var(--line) !important;
|
||
box-shadow:none !important; gap:16px !important; padding:0 20px !important; height:58px;
|
||
}
|
||
.admin-brand{ order:0; display:flex; align-items:center; gap:10px; font-weight:800; font-size:1rem;
|
||
color:var(--text); white-space:nowrap; letter-spacing:-.02em; }
|
||
.admin-brand .admin-logo{ height:26px; width:auto; display:block; }
|
||
@media (prefers-color-scheme: dark){ .admin-brand .admin-logo{ filter:brightness(0) invert(1); } }
|
||
.tabs{ order:1; gap:2px !important; }
|
||
.spacer{ order:2; }
|
||
#you-button-container{ order:3; }
|
||
#you-button, #you-button.btn, .navbar #you-button{
|
||
background:transparent !important; color:var(--text) !important; border:1px solid var(--line) !important;
|
||
border-radius:999px !important; font-weight:600 !important; padding:5px 12px 5px 6px !important;
|
||
}
|
||
#you-button:hover{ transform:none !important; border-color:var(--primary) !important; box-shadow:none !important; }
|
||
#you-button .profile-img2{ width:26px !important; height:26px !important; }
|
||
.navbar button, .tabs .tab{
|
||
background:transparent !important; color:var(--muted) !important; border:none !important;
|
||
border-radius:8px !important; font-weight:600 !important; font-size:.85rem !important;
|
||
padding:8px 12px !important; transition:color .12s, background .12s !important;
|
||
}
|
||
.navbar button:hover, .tabs .tab:hover{
|
||
transform:none !important; box-shadow:none !important; opacity:1 !important;
|
||
color:var(--text) !important; background:var(--panel) !important; border:none !important;
|
||
}
|
||
.navbar button.active, .tabs .tab.active{
|
||
background:var(--primary-soft) !important; color:var(--primary) !important; border:none !important;
|
||
box-shadow:none !important; font-weight:700 !important;
|
||
}
|
||
|
||
/* ── content: a real left-aligned, contained page ── */
|
||
.content{ text-align:left !important; max-width:none !important; width:100% !important; margin:0 !important;
|
||
padding:84px 32px 56px !important; box-sizing:border-box; }
|
||
.content h2{ color:var(--text) !important; font-weight:800 !important; font-size:1.6rem !important;
|
||
letter-spacing:-.02em; margin:0 0 4px !important; text-align:left !important; }
|
||
.content > h2 + .stat-cards, .content > h2{ margin-top:0; }
|
||
.content h3{ text-align:left !important; }
|
||
.content p{ color:var(--muted) !important; text-align:left !important; }
|
||
.dashboard-header{ margin-bottom:26px; }
|
||
section.dashboard, .dashboard{ justify-content:start !important; }
|
||
|
||
/* section labels that were centered h2s ("Quick Links") */
|
||
.content h2, .content h3, .section-title{ text-align:left !important; }
|
||
|
||
/* ── metric / stat cards ── */
|
||
.stat-cards{ max-width:none !important; margin:16px 0 30px !important; }
|
||
.stat-card, .card, .card_2{
|
||
background:var(--card) !important; border:1px solid var(--line) !important; color:var(--text) !important;
|
||
box-shadow:var(--shadow) !important; backdrop-filter:none !important; text-align:left !important;
|
||
border-radius:12px !important;
|
||
}
|
||
.stat-card{ padding:16px 18px !important; }
|
||
.stat-label, .stat-card .stat-label{ display:block; color:var(--muted) !important; font-size:.72rem !important;
|
||
font-weight:700 !important; text-transform:uppercase; letter-spacing:.05em; }
|
||
.stat-value, .stat-card .stat-value{ display:block; color:var(--text) !important; font-size:1.5rem !important;
|
||
font-weight:800 !important; margin-top:4px; letter-spacing:-.02em; }
|
||
.card:hover, .card_2:hover{ background:var(--card) !important; border-color:var(--line) !important;
|
||
transform:none !important; box-shadow:0 4px 14px rgba(16,24,40,.08) !important; }
|
||
.card h3, .card_2 h3{ color:var(--text) !important; font-size:1rem !important; font-weight:700 !important;
|
||
margin:0 0 6px !important; text-align:left !important; }
|
||
.card p, .card_2 p{ color:var(--muted) !important; font-size:1.35rem !important; font-weight:800 !important;
|
||
letter-spacing:-.02em; margin:0 0 14px !important; text-align:left !important; }
|
||
.card p:empty{ display:none; }
|
||
|
||
/* ── tables ── */
|
||
.table, table.table{ background:var(--card) !important; color:var(--text) !important; border:1px solid var(--line) !important;
|
||
border-radius:12px; box-shadow:var(--shadow) !important; overflow:hidden; text-align:left; }
|
||
.table thead{ background:var(--panel) !important; }
|
||
.table th{ color:var(--muted) !important; text-transform:uppercase; letter-spacing:.04em; font-size:.7rem !important;
|
||
font-weight:700 !important; text-align:left !important; padding:12px 14px !important; }
|
||
.table td{ color:var(--text) !important; border-top:1px solid var(--line) !important; text-align:left !important;
|
||
padding:11px 14px !important; vertical-align:middle; }
|
||
.table-striped tbody tr:nth-of-type(odd){ background-color:rgba(128,128,128,.04) !important; }
|
||
.table tbody tr:hover{ background:var(--panel) !important; }
|
||
.list-item{ border-bottom:1px solid var(--line) !important; }
|
||
.list-item:hover{ background:var(--panel) !important; }
|
||
|
||
/* ── inputs ── */
|
||
input[type=text],input[type=number],input[type=password],input[type=search],input[type=email],
|
||
textarea, select, .form-control{
|
||
background:var(--bg) !important; color:var(--text) !important; border:1px solid var(--line) !important;
|
||
border-radius:8px !important; font-family:inherit !important;
|
||
}
|
||
input::placeholder, textarea::placeholder{ color:var(--muted) !important; }
|
||
input:focus, textarea:focus, select:focus, .form-control:focus{ outline:none !important;
|
||
border-color:var(--primary) !important; box-shadow:0 0 0 3px var(--primary-soft) !important; }
|
||
|
||
/* ── buttons: one restrained solid accent, no rainbow ── */
|
||
.btn{ border-radius:8px !important; font-weight:600 !important; font-family:inherit !important;
|
||
font-size:.85rem !important; padding:8px 14px !important; transition:filter .12s, background .12s, border-color .12s !important;
|
||
box-shadow:none !important; }
|
||
.btn:hover{ transform:none !important; }
|
||
.btn-info, .btn-primary{ background:var(--primary) !important; border:1px solid var(--primary) !important; color:#fff !important; }
|
||
.btn-info:hover, .btn-primary:hover{ background:var(--primary-dark) !important; border-color:var(--primary-dark) !important; }
|
||
.btn-success{ background:var(--ok) !important; border:1px solid var(--ok) !important; color:#fff !important; }
|
||
.btn-danger{ background:var(--danger) !important; border:1px solid var(--danger) !important; color:#fff !important; }
|
||
.btn-secondary, .btn-light, .btn-default{ background:var(--panel) !important; border:1px solid var(--line) !important; color:var(--text) !important; }
|
||
/* "View …" navigation buttons were orange btn-warning — make them a neutral secondary that matches the site */
|
||
.btn-warning, .btn-room-view, .btn-edit{ background:var(--panel) !important; border:1px solid var(--line) !important; color:var(--text) !important; }
|
||
.btn-warning:hover, .btn-room-view:hover, .btn-edit:hover{ border-color:var(--primary) !important; color:var(--primary) !important; background:var(--primary-soft) !important; }
|
||
|
||
/* action cells: a tidy wrapping button cluster so nothing overlaps */
|
||
.table td .btn{ margin:3px 4px 3px 0 !important; vertical-align:top; }
|
||
.cell-actions{ display:flex; flex-wrap:wrap; gap:6px; max-width:300px; align-items:flex-start; }
|
||
.cell-actions .btn{ margin:0 !important; white-space:nowrap; }
|
||
|
||
/* card actions → quiet ghost buttons, not a wall of filled blocks */
|
||
.card .btn, .card_2 .btn, .card button, .card_2 button{
|
||
background:transparent !important; border:1px solid var(--line) !important; color:var(--text) !important;
|
||
width:auto !important; padding:7px 13px !important; font-weight:600 !important; margin-top:0 !important; }
|
||
.card .btn:hover, .card_2 .btn:hover, .card button:hover, .card_2 button:hover{
|
||
border-color:var(--primary) !important; color:var(--primary) !important; background:var(--primary-soft) !important; }
|
||
|
||
/* role toggles (Dev/Mod/Support/Influencer…) */
|
||
.btn-role{ background:transparent !important; border:1px solid var(--line) !important; color:var(--text) !important; border-radius:8px !important; font-weight:600 !important; }
|
||
.btn-role:hover{ background:var(--primary-soft) !important; border-color:var(--primary) !important; color:var(--primary) !important; }
|
||
.btn-role.role-on{ background:var(--primary) !important; border-color:var(--primary) !important; color:#fff !important; box-shadow:none !important; }
|
||
.btn-role.role-on:hover{ background:var(--primary-dark) !important; color:#fff !important; }
|
||
|
||
/* popups / modals */
|
||
.popup, .modal-content{ background:var(--card) !important; color:var(--text) !important; border:1px solid var(--line) !important; border-radius:14px !important; box-shadow:0 20px 60px rgba(0,0,0,.28) !important; }
|
||
.popup h2, .modal-content h2{ text-align:left; }
|
||
|
||
/* ── responsive / mobile ── */
|
||
@media (max-width:900px){
|
||
.navbar{ position:sticky !important; top:0; flex-wrap:nowrap !important; padding:0 10px !important; gap:8px !important; }
|
||
.navbar .spacer{ display:none !important; }
|
||
.admin-brand span{ display:none; } /* logo mark only on small screens */
|
||
#you-button-container{ flex:0 0 auto; }
|
||
.tabs{ overflow-x:auto !important; flex-wrap:nowrap !important; -webkit-overflow-scrolling:touch; scrollbar-width:none; }
|
||
.tabs::-webkit-scrollbar{ display:none; }
|
||
.tabs .tab, .navbar button{ flex:0 0 auto !important; white-space:nowrap !important; }
|
||
.content{ margin:0 auto !important; padding:16px 14px 60px !important; }
|
||
.content h2{ font-size:1.35rem !important; }
|
||
.dashboard, .stat-cards{ grid-template-columns:1fr !important; }
|
||
.table, table.table{ display:block !important; overflow-x:auto !important; white-space:nowrap; }
|
||
.popup, .modal-content{ max-width:95vw !important; }
|
||
}
|
||
@media (max-width:480px){
|
||
#you-button{ font-size:0 !important; padding:4px !important; }
|
||
#you-button .profile-img2{ margin-right:0 !important; }
|
||
.tabs .tab{ font-size:.82rem !important; padding:8px 10px !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body class="campus">
|
||
<div id="background-blur-layer"></div>
|
||
<div class="space-background" id="space-background"></div>
|
||
<div class="popup-overlay" id="alert-popup">
|
||
<div class="popup">
|
||
<img id="popup-icon" src="" alt="Icon">
|
||
<h2 id="popup-title">null</h2>
|
||
<p id="popup-text">null</p>
|
||
<button class="close-btn" onclick="closePopup('alert-popup')">Close</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="ban-modal" class="popup-overlay">
|
||
<div class="popup" style="max-width: 520px;">
|
||
<h2 id="banTitle">Ban Player</h2>
|
||
|
||
<img id="accountimage1"
|
||
style="margin: 0 auto 12px; width:128px; height:128px; border-radius:10px; display:none;">
|
||
|
||
<div id="banStatus" style="margin-bottom:10px; display:none;"></div>
|
||
|
||
<input type="hidden" id="playerid">
|
||
|
||
<div class="ban-form">
|
||
|
||
<label>Ban Reason</label>
|
||
<input type="text" id="banmsg" placeholder="Enter reason for ban" required>
|
||
|
||
<label>Duration</label>
|
||
<div class="ban-time">
|
||
<input type="number"
|
||
id="banAmount"
|
||
min="1"
|
||
placeholder="Amount">
|
||
|
||
<select id="banUnit">
|
||
<option value="seconds">Seconds</option>
|
||
<option value="minutes" selected>Minutes</option>
|
||
<option value="hours">Hours</option>
|
||
<option value="days">Days</option>
|
||
<option value="years">Years</option>
|
||
<option value="permanent">Permanent</option>
|
||
</select>
|
||
</div>
|
||
|
||
<label>Category</label>
|
||
<select id="banCategory">
|
||
<option value="-1">Moderator</option>
|
||
<option value="2">Harassment</option>
|
||
<option value="3">Cheating</option>
|
||
<option value="5">AFK</option>
|
||
<option value="6">Misc</option>
|
||
<option value="7">Underage</option>
|
||
<option value="101">Sexual Behaviour</option>
|
||
<option value="102">Discrimination</option>
|
||
<option value="103">Trolling</option>
|
||
</select>
|
||
|
||
<div class="ban-appeal-toggle">
|
||
<label class="switch">
|
||
<input type="checkbox" id="includeAppeal">
|
||
<span class="slider round"></span>
|
||
</label>
|
||
|
||
<div class="appeal-text">
|
||
<strong>Add appeal message</strong>
|
||
<span>"To appeal this ban, Make a ticket in https://discord.gg/xH8MMwK5Bk."</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ban-appeal-toggle">
|
||
<label class="switch">
|
||
<input type="checkbox" id="banDevice">
|
||
<span class="slider round"></span>
|
||
</label>
|
||
|
||
<div class="appeal-text">
|
||
<strong>Also ban their device</strong>
|
||
<span>Blocks every account on that device, checked at login.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ban-appeal-toggle">
|
||
<label class="switch">
|
||
<input type="checkbox" id="banIp">
|
||
<span class="slider round"></span>
|
||
</label>
|
||
|
||
<div class="appeal-text">
|
||
<strong>Also ban their IP</strong>
|
||
<span>Blocks every known address for this account. May affect others sharing it.</span>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
</div>
|
||
|
||
|
||
<div style="display:flex; gap:10px; justify-content:flex-end;">
|
||
<button class="btn btn-secondary" onclick="closeBanModal()">Cancel</button>
|
||
<button class="btn btn-danger" onclick="ban()">Ban</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div class="popup-overlay" id="confirm-popup">
|
||
<div class="popup">
|
||
<h2 id="confirm-title">Confirm</h2>
|
||
<p id="confirm-text">Nice confirm.</p>
|
||
<div class="button-container">
|
||
<button class="confirm-btn" id="confirm-action">Confirm</button>
|
||
<button class="cancel-btn" onclick="closePopup('confirm-popup')">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="popup-overlay" id="player-popup">
|
||
<div class="player-popup">
|
||
|
||
<!-- HEADER -->
|
||
<div class="popup-header">
|
||
<img id="player-popup-icon" src="" alt="Icon">
|
||
|
||
<div class="popup-header-text">
|
||
<h2 id="player-popup-title">null</h2>
|
||
<p id="player-popup-text">null</p>
|
||
<div id="banStatus" style="margin-bottom:10px; display:none;"></div>
|
||
<div id="player-popup-platforms" class="player-platforms"></div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div class="player-section">
|
||
<h4 class="section-title">Bio</h4>
|
||
<p id="player-popup-bio">This player has not written a bio yet.</p>
|
||
</div>
|
||
|
||
|
||
<!-- BUTTONS -->
|
||
<div id="popup-buttons"></div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div class="navbar">
|
||
<div class="admin-brand"><img class="admin-logo" src="/admin-assets/AnimatedIcon.png" alt="Campus"><span>Campus Admin</span></div>
|
||
<div id="you-button-container" class="d-flex align-items-center">
|
||
<button id="you-button" class="btn btn-info" onclick="viewProfile()">
|
||
<img id="profile-image" src="/img/DefaultImg.png" alt="Profile" class="profile-img2"
|
||
style="width: 30px; height: 30px; margin-right: 10px; border-radius: 50%;">
|
||
YOU
|
||
</button>
|
||
</div>
|
||
|
||
<div class="spacer"></div>
|
||
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="home"><i class="fas fa-home"></i> Dashboard</button>
|
||
<button class="tab" data-tab="Players"><i class="fas fa-user"></i> Players</button>
|
||
<button class="tab" data-tab="Rooms"><i class="fas fa-folder-open"></i> Rooms</button>
|
||
<button class="tab" data-tab="Featured"><i class="fa-solid fa-star"></i> Featured</button>
|
||
<button class="tab" data-tab="Server"><i class="fa fa-server"></i> Server</button>
|
||
<button class="tab" data-tab="Coach"><i class="fa-solid fa-puzzle-piece"></i> Coach</button>
|
||
<button class="tab" data-tab="EditPlayer"><i class="fa-solid fa-user-pen"></i> Edit Player</button>
|
||
<button class="tab" data-tab="Events"><i class="fa-solid fa-calendar-day"></i> Events</button>
|
||
<button class="tab" data-tab="Photos"><i class="fa-solid fa-camera"></i> Photos</button>
|
||
<button class="tab" data-tab="Comments"><i class="fa-solid fa-comment-dots"></i> Comments</button>
|
||
<button class="tab" data-tab="Reports"><i class="fa-solid fa-flag"></i> Reports <span id="reportsBadge" style="display:none; background:#c0392b; color:#fff; border-radius:10px; padding:1px 7px; font-size:12px; margin-left:4px;"></span></button>
|
||
<button class="tab" data-tab="Staff"><i class="fa-solid fa-user-shield"></i> Staff</button>
|
||
<button class="tab" data-tab="Gifts"><i class="fa-solid fa-gift"></i> Gifts</button>
|
||
<button class="tab" data-tab="Economy"><i class="fa-solid fa-coins"></i> Economy</button>
|
||
<button class="tab" data-tab="Database"><i class="fa-solid fa-database"></i> Database</button>
|
||
<button class="tab" data-tab="Log"><i class="fa-solid fa-terminal"></i> Log</button>
|
||
<button class="tab" data-tab="Settings"><i class="fa-solid fa-gear"></i> Settings</button>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.log-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 12px 0; }
|
||
.log-toolbar input[type="text"] { flex: 1 1 320px; min-width: 200px; padding: 9px 12px; border-radius: 8px;
|
||
border: 1px solid rgba(255,255,255,.18); background: rgba(0,0,0,.25); color: #eee; font-family: inherit; }
|
||
.log-toolbar select { padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,.18);
|
||
background: rgba(0,0,0,.25); color: #eee; }
|
||
.log-toolbar label { display: inline-flex; align-items: center; gap: 6px; opacity: .85; font-size: 13px; }
|
||
.log-auto { user-select: none; }
|
||
.log-console {
|
||
height: 60vh; min-height: 320px; overflow: auto; margin: 0;
|
||
background: #14141b; color: #d7dae0; border: 1px solid rgba(255,255,255,.12); border-radius: 10px;
|
||
padding: 14px 16px; white-space: pre; tab-size: 4;
|
||
font-family: ui-monospace, "Cascadia Code", "Consolas", monospace; font-size: 12.5px; line-height: 1.5;
|
||
}
|
||
/* Colourise the request-log rows so a failure jumps out. */
|
||
.log-console .l-err { color: #ff6b6b; } /* 4xx/5xx */
|
||
.log-console .l-ok { color: #63c37b; } /* 2xx */
|
||
.log-console .l-dim { color: #7f8694; } /* everything else / timestamps */
|
||
|
||
/* Force-join card + room-status cell */
|
||
.fj-card { background: rgba(0,0,0,.18); border: 1px solid rgba(255,255,255,.12); border-radius: 10px;
|
||
padding: 14px 16px; margin: 8px 0 18px; }
|
||
.fj-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }
|
||
.fj-row label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; opacity: .9; }
|
||
.fj-row input { padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,.18);
|
||
background: rgba(0,0,0,.25); color: #eee; min-width: 160px; }
|
||
.fj-arrow { opacity: .6; margin-bottom: 10px; }
|
||
.rt-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
|
||
.rt-on { background: #63c37b; box-shadow: 0 0 6px #63c37b88; }
|
||
.rt-off { background: #6b6f78; }
|
||
.rt-priv { color: #f6a609; font-weight: 700; }
|
||
</style>
|
||
|
||
<!-- Live server request log (tails Data/APILog.txt via /admin/api/apilog). -->
|
||
<div class="content" id="Log">
|
||
<section class="dashboard-header">
|
||
<h2 id="log-title">Server Log</h2>
|
||
<p class="subtle">Live tail of every request the server handles. Use the filter to isolate one endpoint (e.g. <code>/api/</code>, a player id, or <code>404</code>).</p>
|
||
</section>
|
||
|
||
<div class="log-toolbar">
|
||
<input id="log-filter" type="text" placeholder="Filter (substring, e.g. /Accounts/ or 404)" />
|
||
<label>Lines
|
||
<select id="log-lines">
|
||
<option value="200">200</option>
|
||
<option value="500" selected>500</option>
|
||
<option value="1000">1000</option>
|
||
<option value="2000">2000</option>
|
||
<option value="5000">5000</option>
|
||
</select>
|
||
</label>
|
||
<label class="log-auto"><input id="log-auto" type="checkbox" checked /> Auto-refresh</label>
|
||
<button class="btn btn-info" onclick="refreshLog()"><i class="fa-solid fa-rotate"></i> Refresh</button>
|
||
<button class="btn" onclick="clearLogView()">Clear view</button>
|
||
<span id="log-status" class="subtle"></span>
|
||
</div>
|
||
|
||
<pre id="log-console" class="log-console">Loading log…</pre>
|
||
</div>
|
||
|
||
|
||
<div class="content active" id="home">
|
||
|
||
<!-- Dashboard Header / Hero -->
|
||
<section class="dashboard-header">
|
||
<img id="home-image" src="/img/DefaultImgGreen.png" alt="Dashboard Icon" class="profile-img2 themed-pfp" />
|
||
|
||
<h2 id="dashboard-title">Welcome to the Dashboard</h2>
|
||
|
||
<div class="stat-cards">
|
||
<div class="stat-card">
|
||
<span class="stat-label">Message of the Day</span>
|
||
<span class="stat-value" id="motd_msg">Loading…</span>
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
<span class="stat-label">RAM Usage</span>
|
||
<span class="stat-value" id="ram_mem">Loading…</span>
|
||
</div>
|
||
|
||
<div class="stat-card">
|
||
<span class="stat-label">Page Load Time</span>
|
||
<span class="stat-value" id="ms_ping">Loading…</span>
|
||
</div>
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<!-- Main Dashboard Cards -->
|
||
<section class="dashboard">
|
||
<div class="card hidden">
|
||
<h3>Logs</h3>
|
||
<p id="logs">Check out logs</p>
|
||
<button class="btn btn-info" onclick="setup_html_show('Logs')">View Logs</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Online Players</h3>
|
||
<p id="onlinePlayers">Loading...</p>
|
||
<button class="btn btn-info" onclick="viewOnlinePlayers()">View who is online</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Total Rooms</h3>
|
||
<p id="totalRooms">Loading...</p>
|
||
<button class="btn btn-info" onclick="setup_html_show('Rooms')">View All Rooms</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Total Players</h3>
|
||
<p id="totalPlayers">Loading...</p>
|
||
<button class="btn btn-info" onclick="setup_html_show('Players')">View All Players</button>
|
||
</div>
|
||
|
||
<div class="card hidden">
|
||
<h3>Total Clubs</h3>
|
||
<p id="clubs">Loading...</p>
|
||
<button class="btn btn-info" onclick="setup_html_show('Clubs')">View All Clubs</button>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Total Inventions</h3>
|
||
<p id="inventions">Loading...</p>
|
||
<button class="btn btn-info" onclick="setup_html_show('Inventions')">View All Inventions</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Quick Links -->
|
||
<section class="quick-links">
|
||
<h2>Quick Links</h2>
|
||
<div class="dashboard" id="dashboard_quicklink"></div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
|
||
<div id="onlinePlayersPopup" class="popup">
|
||
<div class="popup-content">
|
||
<h2>Online Players</h2>
|
||
<ul id="onlinePlayersList"></ul>
|
||
<button class="btn btn-danger" onclick="closeonlineplayers()">Close</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content" id="Players">
|
||
<h2>Players <span id="players-live" style="font-size:.8rem; font-weight:400; color:var(--muted); margin-left:10px;"></span></h2>
|
||
|
||
<div class="themed-form-container">
|
||
<form id="playerSearchForm" style="display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start;">
|
||
<input type="text" placeholder="Player ID" id="searchPlayerId" style="width: 100px;">
|
||
<input type="text" placeholder="DisplayName" id="searchDisplayName" style="width: 130px;">
|
||
<input type="text" placeholder="Email (Bio partial)" id="searchEmail" style="width: 160px;">
|
||
<input type="text" placeholder="Min Level" id="searchMinLevel" style="width: 100px;">
|
||
<select id="searchJrState" style="width: 80px;">
|
||
<option value="N/A">N/A</option>
|
||
<option value="Jr">Jr</option>
|
||
<option value="Non-Jr">Non-Jr</option>
|
||
</select>
|
||
<input type="text" placeholder="IP" id="searchIp" style="width: 100px;">
|
||
<input type="text" placeholder="Device ID" id="searchDeviceId" style="width: 100px;">
|
||
<input type="text" placeholder="Platform ID" id="searchPlatformId" style="width: 100px;">
|
||
|
||
<select id="searchOrder" style="margin-top: 10px; width: 140px;">
|
||
<option value="NewestAccount">NewestAccount</option>
|
||
<option value="OldestAccount">OldestAccount</option>
|
||
</select>
|
||
<button type="button" class="btn btn-danger" style="margin-top: 10px;" onclick="searchPlayers()">
|
||
Search
|
||
players
|
||
</button>
|
||
|
||
<div style="display: flex; flex-direction: column; flex-wrap: wrap; margin-top: 10px; gap: 4px;">
|
||
<label>
|
||
<input type="checkbox" id="searchDisplayHistory"> Search DisplayName history [no partial
|
||
matches]?
|
||
</label>
|
||
<label><input type="checkbox" id="searchPartialMatches"> Find partial text matches?</label>
|
||
<label><input type="checkbox" id="searchJuniorsOnly"> Juniors Only</label>
|
||
<label>
|
||
<input type="checkbox" id="searchBannedOnly"> Banned (excluding timeouts & related player
|
||
bans)
|
||
</label>
|
||
<label><input type="checkbox" id="searchOnlineOnly"> Online</label>
|
||
<label><input type="checkbox" id="searchIgnoreNoBirthday"> Ignore No Birthday</label>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Force join: move one player into another player's current room instance. -->
|
||
<div class="fj-card">
|
||
<h4 class="section-title"><i class="fa-solid fa-people-arrows"></i> Force Join</h4>
|
||
<p class="subtle">Drop one player into another's room. The <b>joiner</b> is moved into the <b>target</b>'s current instance (the target must be in a room).</p>
|
||
<div class="fj-row">
|
||
<label>Person joining <input id="fj-joiner" type="text" inputmode="numeric" placeholder="joiner player id" /></label>
|
||
<i class="fa-solid fa-arrow-right fj-arrow"></i>
|
||
<label>Person being joined <input id="fj-target" type="text" inputmode="numeric" placeholder="target player id" /></label>
|
||
<button class="btn btn-info" onclick="forceJoin()"><i class="fa-solid fa-people-arrows"></i> Force Join</button>
|
||
<span id="fj-status" class="subtle"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<table class="table table-striped">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Id</th>
|
||
<th scope="col">Profile Image</th>
|
||
<th scope="col">Username</th>
|
||
<th scope="col">Platform Id</th>
|
||
<th scope="col">Device Id</th>
|
||
<th scope="col">Level</th>
|
||
<th scope="col">Player Created At</th>
|
||
<th scope="col">Last Online</th>
|
||
<th scope="col">Room</th>
|
||
<th scope="col">Discord</th>
|
||
<th scope="col">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="player-list">
|
||
</tbody>
|
||
</table>
|
||
|
||
<a style="cursor: pointer;" onclick="setup_html_show(`CreatePlayer`);" class="btn btn-success">Create Player</a>
|
||
</div>
|
||
<div class="content" id="Server">
|
||
<h2>Server</h2>
|
||
<div class="table" style="margin-bottom:18px;">
|
||
<h3>Server control</h3>
|
||
<p style="color:var(--muted); margin:0 0 12px;">Restart the game server process. Players are briefly disconnected; it comes back in a few seconds.</p>
|
||
<button class="btn btn-danger" onclick="restartServer()"><i class="fa-solid fa-power-off"></i> Restart Server</button>
|
||
<span id="server-restart-status" style="margin-left:12px; color:var(--muted); font-weight:600;"></span>
|
||
</div>
|
||
<div class="table" style="margin-bottom:18px;" id="social-apps">
|
||
<h3>Social sign-ins (website Services tab)</h3>
|
||
<p style="color:var(--muted); margin:0 0 12px;">Each service needs its own developer app. Paste the app's client id and secret here - saved to <code>Data/social_apps.json</code> on the server, never shown again. Register this redirect URI on every app: <code id="social-redirect-example">https://gameauth.ddns.net/api/site/social/<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>
|
||
<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>
|
||
|
||
<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="background:var(--table-bg); border-radius:10px; padding:18px; margin-bottom:22px;">
|
||
<h4 class="section-title">Import rooms</h4>
|
||
<p style="opacity:.7; margin-top:0; font-size:14px;">
|
||
Put a room JSON file in <code>Data/Imports</code> on the server, then enter its
|
||
filename here. See <code>Data/Imports/README.md</code> for the required shape.
|
||
Blobs are a separate step — the result lists any <code>.room</code> files still needed.
|
||
</p>
|
||
<input type="text" id="importFile" placeholder="rooms.json" style="display:block; width:100%; padding:10px; font-size:16px; border:1px solid #ccc; border-radius:5px; margin-bottom:10px; box-sizing:border-box;" />
|
||
<button onclick="importRooms();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Import</button>
|
||
<pre id="importResult" style="white-space:pre-wrap; margin:12px 0 0; opacity:.85; font-size:13px;"></pre>
|
||
</div>
|
||
|
||
<button onclick="eventsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:14px;">Refresh</button>
|
||
|
||
<div style="background:var(--table-bg); border-radius:10px; padding:18px;">
|
||
<table class="table table-striped" style="width:100%;">
|
||
<thead>
|
||
<tr>
|
||
<th>Id</th><th>Name</th><th>Creator</th><th>Room</th><th>Starts</th><th>Going</th><th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="eventsRows">
|
||
<tr><td colspan="7" style="opacity:.7;">Press Refresh to load.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content" id="Photos">
|
||
<h2>Photos</h2>
|
||
<p style="opacity:.75; margin-top:0;">Every photo taken in the game, newest first, attributed to the player who took it.</p>
|
||
<button onclick="photosLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:14px;">Refresh</button>
|
||
<div id="photosSummary" style="opacity:.8; margin-bottom:12px;"></div>
|
||
<div id="photoGrid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(190px,1fr)); gap:16px;"></div>
|
||
</div>
|
||
|
||
<div class="content" id="Comments">
|
||
<h2>Room Comments</h2>
|
||
<p style="opacity:.75; margin-top:0;">Notes players pin in a room's scene, newest first, attributed to the author.</p>
|
||
<button onclick="commentsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px; margin-bottom:14px;">Refresh</button>
|
||
<div id="commentsSummary" style="opacity:.8; margin-bottom:12px;"></div>
|
||
<div id="commentsList" style="display:flex; flex-direction:column; gap:10px;"></div>
|
||
</div>
|
||
|
||
<div class="content" id="Reports">
|
||
<h2>Player Reports</h2>
|
||
<p style="opacity:.75; margin-top:0;">Reports raised from the in-game report menu and the website's Report buttons, newest first. Acting on a player also closes their other open reports, so a pile-on does not leave duplicates of what you just handled.</p>
|
||
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px;">
|
||
<button onclick="reportsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Refresh</button>
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="reportsShowAll" onchange="reportsLoad();" /> Include resolved</label>
|
||
<input type="text" id="reportsPlayerFilter" placeholder="Filter by reported player id" style="padding:9px; font-size:15px; border:1px solid #ccc; border-radius:5px; min-width:230px;" onkeydown="if(event.key==='Enter')reportsLoad();" />
|
||
</div>
|
||
<div id="reportsSummary" style="opacity:.8; margin-bottom:12px;"></div>
|
||
<div id="reportsList" style="display:flex; flex-direction:column; gap:10px;"></div>
|
||
|
||
<h2 style="margin-top:30px;">Bug Reports</h2>
|
||
<p style="opacity:.75; margin-top:0;">What players send from the watch's <b>Report a bug</b>. Every field the client posted is kept (expand <i>raw</i>); attached screenshots / logs are linked.</p>
|
||
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px;">
|
||
<button onclick="bugsLoad();" style="background:var(--card-hover-bg); color:white; padding:10px 15px; font-size:16px; border:none; cursor:pointer; border-radius:5px;">Refresh</button>
|
||
<label style="display:flex; gap:6px; align-items:center;"><input type="checkbox" id="bugsShowAll" onchange="bugsLoad();" /> Include resolved</label>
|
||
</div>
|
||
<div id="bugsSummary" style="opacity:.8; margin-bottom:12px;"></div>
|
||
<div id="bugsList" style="display:flex; flex-direction:column; gap:10px;"></div>
|
||
</div>
|
||
|
||
<div class="content" id="Staff">
|
||
<h2>Staff & 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>
|
||
</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>
|
||
|
||
<!-- ================= 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>
|
||
|
||
<!-- ================= DANGER ZONE ================= -->
|
||
<!-- Kill-switch for in-game bearer tokens. Website/admin sessions are NOT touched. -->
|
||
<div class="card" style="margin-bottom:18px; border:1px solid rgba(255,80,80,.40);">
|
||
<h3><i class="fa-solid fa-triangle-exclamation"></i> Danger Zone</h3>
|
||
<p style="opacity:.85; margin:6px 0 12px;">
|
||
Expire <strong>every in-game sign-in</strong> at once. Use this if a token or password
|
||
leaked: all players are signed out of the <strong>game</strong> and must log in again.
|
||
Website and admin logins are <strong>not</strong> affected.
|
||
</p>
|
||
<button class="btn" style="background:#c0392b; color:#fff;" onclick="revokeGameTokens()">
|
||
<i class="fa-solid fa-key"></i> Expire all in-game tokens
|
||
</button>
|
||
<p id="revokeState" style="opacity:.7; font-size:13px; margin-top:10px;"></p>
|
||
</div>
|
||
|
||
<div class="dropdown-container" style="margin-bottom: 10px;">
|
||
<label for="background-selector" style="font-size: 16px; color: white;">Select Background Image</label>
|
||
<select id="background-selector" style="margin-left: 1.5px; padding: 5px;">
|
||
<option value="Stary_background">Stary Background (OG)</option>
|
||
<option value="dorm_room.jpg">Dorm Room</option>
|
||
<option value="rec_center.png">Rec Center</option>
|
||
<option value="FriendFest2.png">Friend Fest</option>
|
||
<option value="Orientation.png">Orientation</option>
|
||
<option value="Lounge">Lounge</option>
|
||
<option value="3dcharades.png">3D Charades</option>
|
||
<option value="wwww.png">Cool thing idk what is it</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="dropdown-container" style="margin-bottom: 10px;">
|
||
<label for="Theme" style="font-size: 16px; color: white;">Select Theme</label>
|
||
<select id="theme-selector" style="margin-left: 10px; padding: 5px;">
|
||
<option value="campus_theme" selected>Campus (default)</option>
|
||
<option value="red_theme">Red Theme</option>
|
||
<option value="green_theme">Green Theme</option>
|
||
<option value="blue_theme">Blue Theme</option>
|
||
<option value="purple_theme">Purple Theme (OG)</option>
|
||
<option value="pink_theme">Pink Theme</option>
|
||
<option value="orange_theme">Orange Theme</option>
|
||
<option value="yellow_theme">Yellow Theme</option>
|
||
<option value="dark_theme">Dark Theme</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="MuiFormGroup-root css-1mgm2yh-control">
|
||
<label class="MuiFormLabel-root MuiFormLabel-colorPrimary css-bngtud-label">
|
||
Enable Mobile Support
|
||
(beta)
|
||
</label>
|
||
<label class="switch">
|
||
<input type="checkbox" id="mobile-support">
|
||
<span class="slider round"></span>
|
||
</label>
|
||
</span>
|
||
</div>
|
||
<div class="MuiFormGroup-root css-1mgm2yh-control">
|
||
<label class="MuiFormLabel-root MuiFormLabel-colorPrimary css-bngtud-label">
|
||
Enable Better Popups
|
||
(beta)
|
||
</label>
|
||
<label class="switch">
|
||
<input type="checkbox" id="better-popup">
|
||
<span class="slider round"></span>
|
||
</label>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="MuiFormGroup-root css-1mgm2yh-control">
|
||
<label class="MuiFormLabel-root MuiFormLabel-colorPrimary css-bngtud-label">allow notifiyed</label>
|
||
<label class="switch">
|
||
<input type="checkbox" id="allow_notifiyed">
|
||
<span class="slider round"></span>
|
||
</label>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content" id="Logs">
|
||
<h2>Logs</h2>
|
||
<div id="logs-content"></div>
|
||
</div>
|
||
|
||
<div class="content" id="Inventions">
|
||
<h2>Inventions</h2>
|
||
<table class="table table-striped">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Invention Id</th>
|
||
<th scope="col">Image</th>
|
||
<th scope="col">Name</th>
|
||
<th scope="col">Created At</th>
|
||
<th scope="col">Modified At</th>
|
||
<th scope="col">Permissions</th>
|
||
<th scope="col">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="inventions_list">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="content" id="Clubs">
|
||
<h2>Clubs</h2>
|
||
<table class="table table-striped">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Club Id</th>
|
||
<th scope="col">Image</th>
|
||
<th scope="col">Name</th>
|
||
<th scope="col">Created At</th>
|
||
<th scope="col">Members</th>
|
||
<th scope="col">None</th>
|
||
<th scope="col">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="clubs_list">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
|
||
<div class="content" id="Rooms">
|
||
<h2>Rooms</h2>
|
||
|
||
<div style="background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px; margin-bottom:22px;">
|
||
<h4 class="section-title">Import rooms (into @Importer)</h4>
|
||
<p style="color:var(--muted); margin-top:0;">Drop a room-export <code>.zip</code> (RoomDetails.json + subrooms + blobs + image) and it's unpacked, imported as a room owned by <b>@Importer</b> (ids reassigned, kept published), with its blobs placed on the CDN so the build loads. You can also drop loose <code>.room</code> blobs — 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="display:flex; gap:10px; align-items:center; margin:10px 0; flex-wrap:wrap;">
|
||
<input id="roomsSearch" type="text" placeholder="Search rooms by name, description, id, or creator id…"
|
||
style="flex:1; min-width:240px; padding:8px 12px; border:1px solid var(--border); border-radius:6px; background:var(--card); color:var(--text);"
|
||
onkeydown="if(event.key==='Enter'){loadRooms(this.value);}">
|
||
<button class="btn btn-info" onclick="loadRooms(document.getElementById('roomsSearch').value)">Search</button>
|
||
<button class="btn btn-info" onclick="loadRooms('')">Clear</button>
|
||
</div>
|
||
<div style="display:flex; gap:10px; align-items:center; margin:6px 0 12px; flex-wrap:wrap;">
|
||
<button id="rooms_prev" class="btn btn-info" onclick="roomsPage(-1)">‹ Prev</button>
|
||
<button id="rooms_next" class="btn btn-info" onclick="roomsPage(1)">Next ›</button>
|
||
<span id="rooms_count" style="color:var(--muted); font-size:.9rem;"></span>
|
||
</div>
|
||
<table class="table table-striped">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Room Id</th>
|
||
<th scope="col">Image</th>
|
||
<th scope="col">Room Name</th>
|
||
<th scope="col">Description</th>
|
||
<th scope="col">Creator Player Id</th>
|
||
<th scope="col">Max Players</th>
|
||
<th scope="col">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="rooms_list">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<script src="/admin/home_page.js"></script>
|
||
<script>
|
||
|
||
function splitTest() {
|
||
|
||
}
|
||
|
||
document.getElementById('mobile-support').addEventListener('change', function () {
|
||
localStorage.setItem('mobileSupport', this.checked);
|
||
toggleMobileSupport();
|
||
});
|
||
|
||
document.getElementById('better-popup').addEventListener('change', function () {
|
||
localStorage.setItem('betterPopup', this.checked);
|
||
toggleBetterPopups();
|
||
});
|
||
|
||
document.getElementById('background-selector').addEventListener('change', function () {
|
||
const selectedImage = this.value;
|
||
if (selectedImage) {
|
||
localStorage.setItem('backgroundImage', selectedImage);
|
||
setBackgroundImage(selectedImage);
|
||
}
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
const savedBackground = localStorage.getItem('backgroundImage');
|
||
if (savedBackground) {
|
||
document.getElementById('background-selector').value = savedBackground;
|
||
setBackgroundImage(savedBackground);
|
||
}
|
||
});
|
||
|
||
function setBackgroundImage(imageName) {
|
||
const backgroundLayer = document.getElementById('background-blur-layer');
|
||
|
||
if (imageName === "Stary_background") {
|
||
backgroundLayer.style.backgroundImage = ``;
|
||
document.getElementById('space-background').style.visibility = "visible";
|
||
return;
|
||
}
|
||
|
||
const imageUrl = `/img/${imageName}`;
|
||
backgroundLayer.style.backgroundImage = `url(${imageUrl})`;
|
||
document.getElementById('space-background').style.visibility = "hidden";
|
||
}
|
||
|
||
|
||
function toggleMobileSupport() {
|
||
const isChecked = document.getElementById('mobile-support').checked;
|
||
if (isChecked) {
|
||
document.body.classList.add('mobile-support');
|
||
} else {
|
||
document.body.classList.remove('mobile-support');
|
||
}
|
||
}
|
||
|
||
function toggleBetterPopups() {
|
||
const isChecked = document.getElementById('better-popup').checked;
|
||
if (isChecked) {
|
||
document.body.classList.add('better-popup');
|
||
} else {
|
||
document.body.classList.remove('better-popup');
|
||
}
|
||
}
|
||
|
||
function closeBanModal() {
|
||
document.getElementById("ban-modal").classList.remove("active");
|
||
}
|
||
|
||
|
||
function create_player() {
|
||
var platformId = document.getElementById("platformid").value;
|
||
var discordId = document.getElementById("discordid").value;
|
||
|
||
if (!platformId) {
|
||
showPopup("Warning!", "Please fill in both Platform ID");
|
||
return;
|
||
}
|
||
|
||
var formData = new FormData();
|
||
formData.append('platform_id', platformId);
|
||
formData.append('discord_id', discordId);
|
||
formData.append('device_id', "1");
|
||
|
||
fetch('/admin/api/create_player', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
showPopup('Success!', 'Player created successfully!');
|
||
viewPlayer(data, false)
|
||
})
|
||
.catch(error => {
|
||
showPopup('Error!', error.message || error);
|
||
});
|
||
}
|
||
|
||
|
||
function ban() {
|
||
var playerId = document.getElementById("playerid").value;
|
||
var message = document.getElementById("banmsg").value;
|
||
var category = document.getElementById("banCategory").value;
|
||
const includeAppeal = document.getElementById("includeAppeal")?.checked === true;
|
||
|
||
const amountInput = document.getElementById("banAmount");
|
||
const unit = document.getElementById("banUnit").value;
|
||
|
||
let totalSeconds = 0;
|
||
|
||
if (unit === "permanent") {
|
||
totalSeconds = INT32_MAX; // backend convention
|
||
} else {
|
||
const amount = parseInt(amountInput.value, 10);
|
||
|
||
if (isNaN(amount) || amount <= 0) {
|
||
alert("Please enter a valid ban duration.");
|
||
return;
|
||
}
|
||
|
||
const multiplier = unitMultipliers[unit];
|
||
totalSeconds = amount * multiplier;
|
||
|
||
// INT32 clamp (absolute safety)
|
||
if (totalSeconds > INT32_MAX) {
|
||
totalSeconds = INT32_MAX;
|
||
}
|
||
}
|
||
|
||
// 🔒 Final payload
|
||
|
||
if (!message) {
|
||
showPopup('Error!', 'Reason is required.');
|
||
return;
|
||
}
|
||
|
||
if (totalSeconds <= 0) {
|
||
showPopup('Error!', 'Duration must be more than 0.');
|
||
return;
|
||
}
|
||
|
||
var formData = new FormData();
|
||
formData.append('playerId', playerId);
|
||
formData.append('message', message);
|
||
formData.append('duration', totalSeconds);
|
||
formData.append('category', category);
|
||
formData.append("includeAppeal", includeAppeal.toString());
|
||
|
||
// Optional device / IP bans. An account ban alone is trivially defeated by
|
||
// making a new account; these are enforced at login, before the account is
|
||
// even looked up.
|
||
formData.append("banDevice", (document.getElementById("banDevice")?.checked === true) ? "1" : "0");
|
||
formData.append("banIp", (document.getElementById("banIp")?.checked === true) ? "1" : "0");
|
||
|
||
fetch('/admin/api/ban_player', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success === false) {
|
||
showPopup('Error!', data.error || 'Unknown error');
|
||
return;
|
||
}
|
||
setup_html_show('players');
|
||
showPopup('Success!', 'Player banned successfully!');
|
||
})
|
||
.catch(error => {
|
||
showPopup('Error!', error.error || error);
|
||
});
|
||
}
|
||
|
||
|
||
|
||
|
||
// ---- Edit Player ---------------------------------------------------
|
||
// Reads/writes another player's @name, display name, profile picture and banner.
|
||
// Every field is optional: blank means "leave it alone", so one Save can change
|
||
// any combination without wiping the rest.
|
||
|
||
function editId() {
|
||
const raw = (document.getElementById('editPlayerId').value || '').trim();
|
||
if (!raw) {
|
||
showPopup('Error!', 'Enter a player id first.');
|
||
return null;
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
function editPreview() {
|
||
const name = (document.getElementById('editprofileimage').value || '').trim();
|
||
const img = document.getElementById('editProfilePreview');
|
||
img.src = name ? ('/img/' + encodeURIComponent(name) + '?width=256&cropSquare=1&sig=p1')
|
||
: '/img/DefaultPFP.png';
|
||
}
|
||
|
||
function editLoad() {
|
||
const id = editId();
|
||
if (!id) return;
|
||
|
||
fetch('/admin/api/get_profile?playerId=' + encodeURIComponent(id), { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const box = document.getElementById('editSummary');
|
||
if (!data.success) {
|
||
box.innerHTML = '<span style="opacity:.8;">' + (data.message || 'Not found.') + '</span>';
|
||
return;
|
||
}
|
||
|
||
document.getElementById('editusername').value = data.username || '';
|
||
document.getElementById('editdisplayname').value = data.displayName || '';
|
||
document.getElementById('editprofileimage').value = data.profileImage || '';
|
||
document.getElementById('editbannerimage').value = data.bannerImage || '';
|
||
const steamIds = data.steamIds || [];
|
||
document.getElementById('editsteamid').value = steamIds.join(', ');
|
||
// '0' means not linked - show it blank rather than a literal zero.
|
||
document.getElementById('editdiscordid').value = (data.discordId && data.discordId !== '0') ? data.discordId : '';
|
||
editPreview();
|
||
editNameLockShow(!!data.nameLocked);
|
||
|
||
const hasDiscord = data.discordId && data.discordId !== '0';
|
||
const discordLabel = hasDiscord
|
||
? ((data.discordUsername ? (data.discordUsername + ' ') : '') + '(' + data.discordId + ')')
|
||
: 'not linked';
|
||
box.innerHTML = '<b>@' + (data.username || '?') + '</b> (id ' + data.playerId + ')<br>'
|
||
+ 'Display name: <b>' + (data.displayName || '-') + '</b><br>'
|
||
+ 'Steam: <b>' + (steamIds.length ? steamIds.join(', ') : 'not linked') + '</b>'
|
||
+ ' | Discord: <b>' + discordLabel + '</b>'
|
||
+ (data.nameLocked ? '<br><b style="color:#e67e22;">Name locked</b> — this player cannot rename themselves.' : '');
|
||
})
|
||
.catch(err => showPopup('Error!', 'Lookup failed: ' + err));
|
||
}
|
||
|
||
// ---- Name lock (Edit Player) ---------------------------------------
|
||
// The button toggles the stored flag; editLoad() re-reads it so the label always shows
|
||
// what the server holds rather than what was clicked.
|
||
let editNameLocked = false;
|
||
function editNameLockShow(locked) {
|
||
editNameLocked = !!locked;
|
||
document.getElementById('editNameLockState').innerHTML = editNameLocked
|
||
? '<b style="color:#e67e22;">LOCKED</b> — 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)';
|
||
}
|
||
|
||
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)+'" style="width:100%; height:150px; object-fit:cover; border-radius:6px; background:rgba(0,0,0,.2);" loading="lazy" onerror="this.style.opacity=.3">' : '<div style="height:150px; border-radius:6px; background:rgba(0,0,0,.2);"></div>';
|
||
const when = (p.createdAt || '').replace('T',' ').slice(0,16);
|
||
const tags = (p.taggedPlayers && p.taggedPlayers.length) ? '<div style="opacity:.6; font-size:12px;">with '+esc(p.taggedPlayers.join(', '))+'</div>' : '';
|
||
return '<div style="background:var(--table-bg); border-radius:8px; padding:8px;">'
|
||
+ img
|
||
+ '<div style="margin-top:6px; font-size:14px;">by <b>@'+esc(p.takenByName)+'</b></div>'
|
||
+ '<div style="opacity:.6; font-size:12px;">room '+esc(p.roomId)+' · '+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 photoDelete(id) {
|
||
if (!confirm('Delete photo ' + id + '? This removes the image and cannot be undone.')) return;
|
||
const form = new URLSearchParams(); form.append('id', id);
|
||
fetch('/admin/api/delete_photo', {
|
||
method:'POST', credentials:'same-origin',
|
||
headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: form.toString()
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => { showPopup(data.success ? 'Deleted' : 'Error!', data.message || 'Failed.'); if (data.success) photosLoad(); })
|
||
.catch(err => showPopup('Error!', 'Delete failed: ' + err));
|
||
}
|
||
|
||
// ---- Reports ---------------------------------------------------------
|
||
// The queue behind the in-game report menu. Every row carries the actions at the
|
||
// bottom of it, so handling a report never means going and finding the player in
|
||
// another tab first.
|
||
let _reportsCache = [];
|
||
|
||
function reportsLoad() {
|
||
const all = document.getElementById('reportsShowAll').checked ? '1' : '';
|
||
const pid = (document.getElementById('reportsPlayerFilter').value || '').trim();
|
||
let url = '/admin/api/reports?take=200';
|
||
if (all) url += '&all=1';
|
||
if (pid) url += '&playerId=' + encodeURIComponent(pid);
|
||
|
||
fetch(url, { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const list = document.getElementById('reportsList');
|
||
const sum = document.getElementById('reportsSummary');
|
||
if (!data.success) { list.innerHTML = ''; sum.textContent = data.message || 'Failed.'; return; }
|
||
|
||
_reportsCache = data.reports || [];
|
||
reportsBadge(data.openCount || 0);
|
||
sum.textContent = _reportsCache.length + ' report(s) shown, ' + (data.openCount || 0) + ' open.';
|
||
if (_reportsCache.length === 0) { list.innerHTML = '<span style="opacity:.7;">Nothing to review.</span>'; return; }
|
||
|
||
list.innerHTML = _reportsCache.map(r => {
|
||
const when = (r.createdAt || '').replace('T', ' ').slice(0, 16);
|
||
const room = r.source === 'website'
|
||
? ('<span style="background:#8e44ad; color:#fff; border-radius:9px; padding:1px 8px; font-size:12px;">website</span> '
|
||
+ (r.subjectLink ? '<a href="' + esc(r.subjectLink) + '" target="_blank" style="color:#9ecbff;">' + esc(r.subject || 'item') + (r.subjectId ? ' #' + esc(r.subjectId) : '') + '</a>' : esc(r.subject || '')))
|
||
: (r.roomId ? ('room ' + esc(r.roomId) + (r.roomName ? ' (' + esc(r.roomName) + ')' : '')) : 'no room');
|
||
const repeat = r.priorReports > 1
|
||
? '<span style="background:#c0392b; color:#fff; border-radius:9px; padding:1px 8px; font-size:12px; margin-left:8px;">' + esc(r.priorReports) + ' reports total</span>' : '';
|
||
const banned = r.reportedIsBanned
|
||
? '<span style="background:#7f8c8d; color:#fff; border-radius:9px; padding:1px 8px; font-size:12px; margin-left:6px;">already banned</span>' : '';
|
||
const state = r.resolved
|
||
? '<div style="opacity:.7; font-size:13px; margin-top:6px;">Resolved: <b>' + esc(r.action || '') + '</b> by ' + esc(r.resolvedByName || '') + (r.moderatorNote ? ' — ' + esc(r.moderatorNote) : '') + '</div>'
|
||
: '';
|
||
|
||
// Actions live at the BOTTOM of the report they act on.
|
||
const actions = r.resolved ? '' :
|
||
'<div style="margin-top:12px; padding-top:12px; border-top:1px solid rgba(255,255,255,.12); display:flex; gap:8px; align-items:center; flex-wrap:wrap;">'
|
||
+ '<input type="text" id="rnote' + r.id + '" placeholder="Note / message shown to the player" style="flex:1; min-width:220px; padding:8px; border:1px solid #ccc; border-radius:5px; box-sizing:border-box;" />'
|
||
+ '<select id="rdur' + r.id + '" style="padding:8px; border-radius:5px;">'
|
||
+ '<option value="60">Ban 1 hour</option>'
|
||
+ '<option value="1440">Ban 1 day</option>'
|
||
+ '<option value="10080">Ban 7 days</option>'
|
||
+ '<option value="43200">Ban 30 days</option>'
|
||
+ '<option value="0">Ban permanently</option>'
|
||
+ '</select>'
|
||
+ '<button onclick="reportAct(' + r.id + ',\'ban\')" style="background:#c0392b; color:#fff; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Ban</button>'
|
||
+ '<button onclick="reportAct(' + r.id + ',\'warn\')" style="background:#d68910; color:#fff; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Warn</button>'
|
||
+ '<button onclick="reportAct(' + r.id + ',\'dismiss\')" style="background:#555; color:#fff; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Dismiss</button>'
|
||
+ '<button onclick="viewProfile(' + r.reportedPlayerId + ')" style="background:#2c3e50; color:#fff; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Profile</button>'
|
||
+ '</div>';
|
||
|
||
return '<div style="background:var(--table-bg); border-radius:8px; padding:14px;' + (r.resolved ? ' opacity:.6;' : '') + '">'
|
||
+ '<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">'
|
||
+ '<div style="min-width:0;">'
|
||
+ '<div style="font-size:15px;"><b>' + esc(r.reportedName) + '</b> <span style="opacity:.6;">#' + esc(r.reportedPlayerId) + '</span>' + repeat + banned + '</div>'
|
||
+ '<div style="opacity:.75; font-size:13px; margin-top:3px;">reported by <b>' + esc(r.reporterName) + '</b> · ' + 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';
|
||
const tag = fromStore
|
||
? '<span style="background:#2980b9; color:#fff; border-radius:9px; padding:1px 7px; font-size:11px; margin-left:6px;">store</span>'
|
||
: '<span style="background:#7f8c8d; color:#fff; border-radius:9px; padding:1px 7px; font-size:11px; margin-left:6px;">catalog</span>';
|
||
const meta = fromStore
|
||
? ('id ' + esc(it.purchasableItemId) + ' · 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, "\\'") + "'";
|
||
return '<div style="background:var(--table-bg); border-radius:8px; padding:10px 12px; display:flex; justify-content:space-between; gap:12px; align-items:center;">'
|
||
+ '<div style="min-width:0;"><b>' + esc(it.name) + '</b>' + tag
|
||
+ '<div style="opacity:.6; font-size:12px; word-break:break-all;">' + meta + '</div></div>'
|
||
+ '<button onclick="giftGive(' + esc(it.purchasableItemId) + ',' + esc(it.storefrontType) + ',this,' + args + ')" style="flex:0 0 auto; background:var(--card-hover-bg); color:white; border:none; padding:8px 14px; border-radius:5px; cursor:pointer;">Give</button>'
|
||
+ '</div>';
|
||
}).join('');
|
||
})
|
||
.catch(err => { if (seq === _giftSearchSeq) sum.textContent = 'Search failed: ' + err; });
|
||
}
|
||
|
||
function giftGive(itemId, storefront, btn, avatarItemDesc) {
|
||
const pid = document.getElementById('giftPlayerId').value.trim();
|
||
if (!pid) { showPopup('Pick a player', 'Enter a player id (or -1 for everyone) at the top.'); return; }
|
||
const mode = document.getElementById('giftMode').value;
|
||
const xp = document.getElementById('giftXp').value || '0';
|
||
const message = document.getElementById('giftMessage').value || '';
|
||
if (pid === '-1' && !confirm('Give this item to EVERY player? (owners are skipped)')) return;
|
||
if (btn) { btn.disabled = true; btn.textContent = '...'; }
|
||
const form = new URLSearchParams();
|
||
form.append('playerId', pid); form.append('purchasableItemId', itemId);
|
||
form.append('storefrontType', storefront); form.append('mode', mode);
|
||
form.append('avatarItemDesc', avatarItemDesc || '');
|
||
form.append('xp', xp); form.append('message', message);
|
||
fetch('/admin/api/gift/give', {
|
||
method: 'POST', credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString()
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => { showPopup(data.success ? 'Sent' : 'Error!', data.message || 'Failed.'); })
|
||
.catch(err => showPopup('Error!', 'Give failed: ' + err))
|
||
.finally(() => { if (btn) { btn.disabled = false; btn.textContent = 'Give'; } });
|
||
}
|
||
|
||
function eventsLoad() {
|
||
fetch('/admin/api/events', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const body = document.getElementById('eventsRows');
|
||
if (!data.success) {
|
||
body.innerHTML = '<tr><td colspan="7" style="opacity:.8;">' + esc(data.message || 'Failed to load.') + '</td></tr>';
|
||
return;
|
||
}
|
||
const rows = data.events || [];
|
||
if (rows.length === 0) {
|
||
body.innerHTML = '<tr><td colspan="7" style="opacity:.7;">No events.</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = rows.map(e =>
|
||
'<tr>' +
|
||
'<td>' + esc(e.PlayerEventId) + '</td>' +
|
||
'<td>' + esc(e.Name) + '<br><span style="opacity:.6; font-size:13px;">' + esc(e.Description) + '</span></td>' +
|
||
'<td>' + esc(e.CreatorPlayerId) + '</td>' +
|
||
'<td>' + esc(e.RoomId) + '</td>' +
|
||
'<td>' + esc((e.StartTime || '').replace('T', ' ').slice(0, 16)) + '</td>' +
|
||
'<td>' + esc(e.AttendeeCount) + '</td>' +
|
||
'<td><button onclick="eventDelete(' + esc(e.PlayerEventId) + ')" style="background:#c0392b; color:white; border:none; padding:6px 12px; border-radius:5px; cursor:pointer;">Delete</button></td>' +
|
||
'</tr>').join('');
|
||
})
|
||
.catch(err => showPopup('Error!', 'Load failed: ' + err));
|
||
}
|
||
|
||
function eventDelete(id) {
|
||
if (!confirm('Delete event ' + id + '? This cannot be undone.')) return;
|
||
const form = new URLSearchParams();
|
||
form.append('eventId', id);
|
||
fetch('/admin/api/delete_event', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: form.toString()
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
showPopup(data.success ? 'Deleted' : 'Error!', data.message || (data.success ? 'Done.' : 'Delete failed.'));
|
||
if (data.success) eventsLoad();
|
||
})
|
||
.catch(err => showPopup('Error!', 'Delete failed: ' + err));
|
||
}
|
||
|
||
function edit_player() {
|
||
const id = editId();
|
||
if (!id) return;
|
||
|
||
const form = new URLSearchParams();
|
||
form.append('playerId', id);
|
||
form.append('username', document.getElementById('editusername').value.trim());
|
||
form.append('displayName', document.getElementById('editdisplayname').value.trim());
|
||
form.append('profileImage', document.getElementById('editprofileimage').value.trim());
|
||
form.append('bannerImage', document.getElementById('editbannerimage').value.trim());
|
||
form.append('steamId', document.getElementById('editsteamid').value.trim());
|
||
form.append('discordId', document.getElementById('editdiscordid').value.trim());
|
||
|
||
fetch('/admin/api/edit_player', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: form.toString()
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
showPopup(data.success ? 'Saved' : 'Error!', data.message || (data.success ? 'Done.' : 'Save failed.'));
|
||
if (data.success) editLoad();
|
||
})
|
||
.catch(err => showPopup('Error!', 'Save failed: ' + err));
|
||
}
|
||
|
||
// Fill the box with a readable random password (no ambiguous 0/O/1/l/I).
|
||
function genPassword() {
|
||
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||
let out = '';
|
||
const rnd = new Uint32Array(12);
|
||
crypto.getRandomValues(rnd);
|
||
for (let i = 0; i < 12; i++) out += chars[rnd[i] % chars.length];
|
||
document.getElementById('editnewpassword').value = out;
|
||
}
|
||
|
||
function reset_password() {
|
||
const id = editId();
|
||
if (!id) return;
|
||
|
||
const pw = (document.getElementById('editnewpassword').value || '').trim();
|
||
if (pw.length < 4) { showPopup('Error!', 'Password must be at least 4 characters.'); return; }
|
||
if (pw.length > 64) { showPopup('Error!', 'Password must be fewer than 64 characters.'); return; }
|
||
|
||
const form = new URLSearchParams();
|
||
form.append('playerId', id);
|
||
form.append('newPassword', pw);
|
||
|
||
fetch('/admin/api/reset_password', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: form.toString()
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
showPopup(data.success ? 'Password reset' : 'Error!', data.message || (data.success ? 'Done.' : 'Reset failed.'));
|
||
if (data.success) document.getElementById('editnewpassword').value = '';
|
||
})
|
||
.catch(err => showPopup('Error!', 'Reset failed: ' + err));
|
||
}
|
||
|
||
// ---- Danger zone: expire ALL in-game bearer tokens ----------------
|
||
function revokeGameTokens() {
|
||
if (!confirm('Expire ALL in-game sign-ins?\n\nEvery player is signed out of the GAME and must log in again. Website and admin logins are NOT affected.')) return;
|
||
const el = document.getElementById('revokeState');
|
||
if (el) el.textContent = 'Expiring…';
|
||
fetch('/admin/api/revoke_game_tokens', { method: 'POST', credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
showPopup(data.success ? 'In-game tokens expired' : 'Error!',
|
||
data.message || (data.success ? 'Done.' : 'Failed.'));
|
||
if (el) el.textContent = data.success
|
||
? (data.kicked + ' connected player(s) were signed out just now.')
|
||
: '';
|
||
})
|
||
.catch(err => { showPopup('Error!', 'Failed: ' + err); if (el) el.textContent = ''; });
|
||
}
|
||
|
||
// ---- Featured Rooms -----------------------------------------------
|
||
function loadFeaturedRooms() {
|
||
fetch('/admin/api/featured_rooms', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const box = document.getElementById('featuredCurrent');
|
||
const ta = document.getElementById('featuredIds');
|
||
const rooms = (data && data.rooms) || [];
|
||
if (!rooms.length) {
|
||
box.innerHTML = '<span style="opacity:.7;">No rooms featured.</span>';
|
||
if (ta) ta.value = '';
|
||
return;
|
||
}
|
||
box.innerHTML = 'Currently featured: ' + rooms.map(r =>
|
||
'<b>' + esc(r.name) + '</b> <span style="opacity:.6;">(' + r.id + ')</span>' +
|
||
(r.exists ? '' : ' <span style="color:#e74c3c;">missing</span>')).join(' | ');
|
||
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,iVBORw0KGgoAAAANSUhEUgAAAjgAAAI4CAYAAABndZP2AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAFBhaW50Lk5FVCA1LjEuMvu8A7YAAAC2ZVhJZklJKgAIAAAABQAaAQUAAQAAAEoAAAAbAQUAAQAAAFIAAAAoAQMAAQAAAAIAAAAxAQIAEAAAAFoAAABphwQAAQAAAGoAAAAAAAAAYAAAAAEAAABgAAAAAQAAAFBhaW50Lk5FVCA1LjEuMgADAACQBwAEAAAAMDIzMAGgAwABAAAAAQAAAAWgBAABAAAAlAAAAAAAAAACAAEAAgAEAAAAUjk4AAIABwAEAAAAMDEwMAAAAADp1fY4ytpsegAAh3RJREFUeF7t3Qe85FZ5N/5H0szctr15vbvuZm3jSjHFYOMaA0mAP3woCaEETADXpeQlCYSaAMEBgjG2wZhmsAEDITQ7QOiEN4S8wTb2rrd6e293b5mi8n+eI517NbqaGelOn/l918ejmTsadZ1HR+foEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHQ54xenDmwNhmvwgtdajOB1tpJOpxJMvz6Yfn0w/fpg+vXB9OuD6deno6ZvG/xz9f4iAAAAQEcxg1cAAACAnrBzvjGOAAcAAAB6ymPHGVsQ4AAAAEBPMTzajQAHAAAAekrO8bYjwAEAAICesmicNiDAAQAAgJ5y2j7vcQQ4AAAA0FNyHjkIcAAAAKBn7JpnjPPLrxM/6O9gjujB+TkyjXBMpEcN/0T4SYKNeIZgrScjNmIa1XTe9PUU/b9g+ZsLy18dlr+5sPxRWH5fby5/+PdMfudP3+Ch848UaWEx2fR+utp45PL13jmJA5wfLcnQ9WedRI4tXzfJlQ8NlzyvOONpgW7wiVdj3cjE5dvazN8RvJAVfqfW+Io381O37Pd4Gar+fuyvhrRufFO9j+BtUEl0/ZUvtxZaf5Hfx/LL/7H81WH5sfzBYASWX/6P5a8uMj4vj54jk/Nuwx1Uw45p02fX76A/OmCr97X8ZLX54yvWu1fJPMStthn+fbFF152+khxHxghmmmfGdByyIr/gBQsVXqF6OLoS/ffTK1mP4n+NA6ngg7TjKzHBTXRj+L87PX45c8b0olo9vr/TRMTs5P7XZq6/eNPjl08Py4/lDwYrwPKr/6vhmbD8WP5gsAIsv/q/Ghbl7zNkuhl+Z5JjFenTW3bT8w45wd+q+89TrLuetcW5RtZXjVnw3b/YpBtOOp7coNqOzIjF85Fx7BkrRegVGi3NiW4g3gTBkE//Vr3jTysPcsq/ze/N6CflTLd8/KhWjz/z1+LHr7T+ZiofP/o1LD+WvxosP5a/Giw/lr+a6PheOFhTBRRZNYeOVaDbt+2j5x9OFuCsXWb83RP3eR9KHuAssujGVcvJMCw1YVlhUnKT4zVZHnSUr1C9gvUKjwYg0QBF/1a9408rX8HRP7d6B41KO36wGkLix6+0/mYqHz86t1h+LH81WH4sfzVYfix/NdHx3UiA4xkmOfwd1yzSp7fvoyv2F4I/Vlc06M8GPPpqqgBnzcogwAmthpxrcVARWYggABJ6xerXGQGKej+9UHo7+F+bWcSWdHwlOl9Mz5fm/26ljYQiSix/MFgBll/9Xw3PhOXH8geDFWD51f/V8Ez9t/zh95JXO6Ysg6tuUd21dR9dnjDAYS/j0e+T9VVjFnwPSICzYjrAUYECBxAmlQc8ShBYhIMJvYJnbLDIytF/19+P1udJOv6USJCjf0crKxKLMSN4i2j1+DOWr8L4ldbfTNXXH5Yfy18Nlh/LXw2WH8tfTXT8cImOxA92xiObHP7cpi9t2csBTjH4a2U75ltjq446Z/Dou6pPPYasAEk6kpSZkMHp5Ac/4eCmmvJx/Q0hSb+vJTyuJD1+paTnTae474RT9PvRFDdOOMWNE061RL8fN424FF0fsxWdfjSFpxmX4sYJp1qi34+bRlzC8vtJv5+t6PSjKTzNuBQ3TjjVEv1+3DTiEpbfT/r9bEWnH03hacaluHHCqZbo9+OmEZew/H7S72crOv1oCk8zLsWNE05JSNBjm1JyI0tU26Yluc3807tkOHUJjhWU2Mi9PZlBXSdHSzjPTZNsFbRPrfWjI2jZOcKatVzR+Wn29LH81WH5/Vcsv/+qYfmbA8tfrv3LP12CI+2ZXMugSXOCziwepPvWF2h+oXqJkfjZ6UM/vGzj5NUynKIER2ZNvu7f10safckKq5ZqiRsnnLpd3DKFU6PJZgunuGmGU7PFTTOcGg3Lj+XH8mP5sfydv/zSSluSFKe8dWc+UXAjBkrutmAweQnO/Ysy9JYVxwd1cGQkPzbS77VoJSiZ0WqikWJU2vGTBl7tUmv96Pd6ufRro5YL0w8GAph+MBDA9P1XTL/8FdP3X+uF6QcDgUrTFzJtL0s0lpmg2zftoucfiny5gvVLs397xv7Sh2U4RQlOaCFrVBwCAAAAqIcEPIbBgUeKAOvkA6XHg8F0AU45/3YVNJZErTpqbgdMH9PH9IM3bYDpY/r9NH09PT3N6HtV4mMaNJLJ+R8kIL2IB4MpIxQpuUHpDQAAADSQDmqizhsv0HmHazcPF7oXcf8dimAAAACgA8ktqmt3jibuRfyx5ebjHCepJuICAQ4AAAB0FnkIIKcKBTuxPMPbHQwqiQMcQ/6ZnIxIkkpAoRQVvqcWl2qJGyecoqLz02kpqtLyRN/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUlR42uHpRd/H/dZsUi0z5oVjDn7nDycwUPKmmogLlOAAAABAR5GwRpKRJDIKLJygTcGgggAHAAAAOoo8lqZkueSleI7y6Xu9zcGgggAHAAAAOoq6VWWlC1HCTcQFApwOU3b/sQ0wfUwf0w/etAGmj+n30/SllKZasnMmDWTKe0uoZOd8c4xfppqICwQ4AAAA0FGks80SJ8tKFuBsXJItayIuEOAAAABAxzn76DE651AheFedQc6eYHAKAhwAAADoKK7h0k3bD9HCfFm1mopyNpU1ERcIcAAAAKCjuIZNlpEsuBELx72yJuICAQ4AAAB0FM/UT8JJ5sQjzlQv4hoCnJbRG6vRKSruO9VSVNx3qqWouO9US1Fx36mWoip9DpBGeB+rlqLivlMtRcV9p1qKivtOtRQV951qKSruO9VSVK2/Q7+QW1Rp+mkYsqkUDE5BgAMAfcPzkj80DADa57zxIp0/mizI3T3PmOCXsibiAgFOnUzDoHw+T67rUrEgtb2rX3nICg8naBV/u0gG5zgO5XI5ZHZ9Sra/HK/y6jglHrZ5X3DKUiU4fltLtlEmkyG7NOPiHHpCKK/0yo+oG3aO0bJkDaho3XHlvYhr0stDorP8A4uytGbVSjI9v026Z/gzk6KbiJ4kJ8rdu3aRw68uH4xTD1KMbCzVM6q8RNaXPMyoLhWmk1ivjl/hc4MsMkyTli5dSgMDA4mfsQC9QS5IisUiHTp8mEr8Opmf9D+P7i78vTgzjt9omNMrx09STR7ftj11nMrFyNw5c2jR4sXBX6A38EWnxBJ6Pwht/49t30bPO5wswvnJE6z/uGKDc2XwdgoCnDrJgbdu7Ua+1OB1keH3dvAH6GjLjl9ICxct4owtcoKFniYBTsm2acuWLRzg8MmrUmFNpd0ifL6T4bg4KOlFS6VzJ8b3yd8lyXt+XbB4hI5fsUL+Aj1DAhqTN+90gKOCEt7mH9uxlZ53qOh/XsOvTjU/f/Fm93XB2ykIcOqUzWbpod8/qraTHICRrjBC/Mg0ur7qLsGZcSYuvwKqrVfHj37uGx/Pk80XBctXLlGlODZndtB/NmzYoF5HRgbVa7TEpkIBTsze1qn7f1KdPL78zaT85CTl8zYtWbKAlvAxC73B5LzQ5O0tx5DD8YR0EWF5sv1dkhZUH9++nZ57KNn5+dHjzHedvdf9x+DtlMQBzv2LMrRmBQc4VB7gmInD9d4kV4SPPLxeDS9dPo88mzeQKmbTr0wVv0UP5NkKflOZzW/2y/j8vej659eJyQmanHDpuOWLaPGipX5NfegD/nY2TYMsK0Nr166jUp5o/qJB/kzOaXzlyH+Tk251Sfe/SjD+tBrj8/FqmhmamJigsbEiLVw0QiuOLy/BaWe/TVAfOdYkoJHSm5I5HeCYVCTHKtJtOw/QZfuT1b2azNArh226J3g7ZTZ7KFRTFtxEhxut3t/t1fH5s7j1r14D+p4v9CfJGOvOHIP9atYwftXx5XgNH7PQcyTICR+G8l4CHceSkpvk2z6uibjAWb6l9AFdT4oT971KKU7c9yqlOHHfq5TixH2vUoqT5DsAaUT3KZ3ixH2vUooT971KKU7c9yqlOHHfq5TixH2vUooT9z1J0C8kyJkqPTUNKmb4k4QXIDvnm+P8MqOJuECA0+30Vc5sr3b6cXw5cGYzLYCo2ex/YRi/vvGhq0VvMarHeHBUctZEic45WvlxDWEblmRim4gLBDgtJpWewgnaCSdUkH1A7wd6uPJ+geMXoPFM1+Tjya+HY1sG3bRznJYmfAYOjzKjF3ENAU5L8AkTVyeto9Y11jekgOMToOWkFXH4OkGqHDumPBsn+dXDQEwv4hoCnFbik6g0fwsndWKtJ0XFfadaior7TrUUFfedaikq7jvVUlTcZwBpRfezIM04fqNixqmaouK+Uy1FxX2nWoqK+061FBX3nWopquxvMX+HniGlNTqVbXPTqPigzTgLJp0ZvYhrCHAAAFjtJuIA0EgqpAliGdWiitP5YxN0/rHkXXOcdNDZEgzOgACnwaai0vCwfq++AQDtIidQOZGqYCY4ocpJUAc38j6udBzHL0Dj6QfdSh4pR9iAQ/TmHUfp+ELMQVjBkBPfRFwgwAEAAIC28AxXpWnJg5s9cym2F3ENAU6L6StBXBECdB8cvwDNIaU4nulV7CYlzrrlxlb+emwTcYEAB/pP8gsEAABoGnWD2A9uDFc9AydNUMKn8opNxAUCnCbT9/v9zTgzQbPUWMO6fyroXwh0O48clzrh+Ox5ur6b1MWR4EaegZP0CcYi43gVm4gL7EEAAADQBjqY9W9RuSrASR7hLJygjcFgLAQ4AAAA0HJyd0PCGV2SY1hmmgIcOm0/bQ4GYyHAAQAAgJaaqr4xlVxyzSIHJcmr71drIi4SBziG/AueMBhOUG5qYwXDUfo5GzpBncL37MNpSnh4pumDK1mqV9xvpkn1ivvNNKlecb+ZJs2e7Ad634juE9MnVPXoeDmtRfYns0KCOsk6DKeQ6XzG47yHtxpvl3DeIyluH6mW6tXo3+t3kgdafPhJMvg4NDN5GsiW7weVVOtFXEv2SwDdTJ04OelXAABoM30+1hcLLj1xfJLOPliUP9a0cWmuYi/iGs72AAAA0HL6ScZChq/bnqcFxYRFY171JuKiKwIcz+uCskCLk4sySwCARnFdlxzX4Twg+AB6jgQ20oJKmomnqWE8YHvbg8GKuiLAkXutjuNQqVikXC5HmUyGTP4sTN5bpkkGBxmSJCiaTUo7fn5ikrJZjm+C2bF4C0nS94j1e51kPsMpXB9HEjSIvqevij7L8WrnbWfwduMNB30lkzGpZBfUPiDkGJXToGHIFUpXnA57nGwDnfg4Dc7FYmJyYuq8q+nzrGmZiZLkEWnSjPN1kDQ9L/JdyZfi8iaoTQIcm5PjJa9gPC/vVG1BJSRPTZStPrAoS2tWrSTTkxMBjyS1vlgrMmW1o1kcHvCOIxG9y8GOzUm/SuAzMTlJhUKBSvmCGkdW2GzoimNJx3eKchASZXNECxfN4+DI4U+lNrhsKDdUES3+5OmGDlYRLrKDBGICmHj8Pf7usWPjVMh7vD9xZmdl+OrQ5p04+EoC9e7u9W5eTL8+bsk/Vkt83JoZPmYXDqnPpzOt5CdYpawPHUhtxvE7/V6d7/l0Oj4xzheSREMjBhWD2xc6H9Snz8hptLKUO2B2oHyEQd55BgYGaGhoSL06vEeqYEgCmyAo0iT4iZ7fIYzzdddfX45l07HBPH1py166fH+yOjiTFr1y2KF7grexOj7AMTlCcDmKV4EN7+0m50wTvMMfPXqUxscL5Eg8w7MiGZbMkpzAxGwDBb08ScfniwJyeJpysly6dD6/4QwzJsBxg/UVrXkvBwjUIWWAc/TomMrcZHOkuFiAHiHHqRyzJf8wpcVLh8quyNWHaSDAqU+NAEeChPHxCeLrVxoeNvnY9dc3ZwlKNNCpJXW+INerIVaWp82fTZ07eH8aHs7Q/PnzaWRkmCy+aFKBDu9kqnSHlw9BTjW8nvj/rmnTqc4o3bP+IC0MtnECL+PNeV8wHKvjA5xcLsM7+BiNjo7S2Ng4TYzKmYnJ5CXxupCdLpeTnSpDAxn/tkOrSnAs/nfo0KjKMBcvrlyCgwCnSRIHOMKksWN5yuddmjdvmE+gnrplkcZs9ystuv3TwvSDgVnyHEfdmty/95h6jwCnzVIFOHKeH5z6W5gZnF9rSbP/eEEUo29DKRxZObZDxaJNDv9ZB0x8/a12nYWLR2iEZ3RkzhwV6ISXR8jtKwQ80zxZP+oYculj23fQ8w77d2Bq2TuXJo87Rqfz6q/aiqrjAhypayNFf7LAcstpcnKCDh48RBMTNgcwRFlOkilZwS0reRXRHT5K79jJT7D+zq25wYlMLfXUQWny/Hp0+PCkmreFC+aQpVYIfzfYaNMHVPmOrpUdPJBe0gAn2H75CZeDHJuG5xANDQ3U3G+g28n+oY9H3l3kgPQyfMyOqyvxxUtG+OIkcpmeRvC7MEs1j1+zLMCRW0Nh6Y/f+raX1N0TckdBvQbZpz6Pjx72byFkOQ6bO3cOLVu2jPMGqeMleVWG7JKDACcgq1IOR8mTLc+mm3fs5AAn2e2pn66mdZevp7OCtxUlzB1aZ3BwUAUtR48eoY0bH6fdu/fxFbdNUjAzMGByyk5V5pKkHgKlg4pGJjlxxSX9twjssx1Mb7cwdZ4KtiVS76ap4zWcoHdEt22NFN0/Uiad31iWBCycOA+SlMlyvsRpeG5GBTe2TRxEj9GGDZvVhXqGMzDbLiG4iSGFB35BQPJ1w1uhZhNx0XEBjkS627dv57SXpIRPdhQp0JkzMkC5AUuV3kiaDmq6g2zAuATtI0/mBoDOpQIKzgekoMavvyvnfD/5eUC7Tc+PkAvwEc6r5s3L0dCQlPATbd68nXbu3EElVfELyrmqJMcxZXsGHyUwYFPVXsS1jgtwNm7cqOrbcFCsdpb583O0cNEimjNnmDKGf1uqk0mEHk5SdKmLL1WTx0gCAIDuJvmSBFyqKTpnXvI4k6HhIZKqC9LwZf/eUdq06fHg21DOJYezdc4Rg/e1zZ/0NgWDVUkMnOhXm1UHJ5u1KJ/Pq2K8Xbt2U2mSyJISmzmDKsDhKIG/5UfHQp5RExYtBdF1bKKf+5V+p/8+JXIPeGr84PvaVB0c9XceR41nkqeKIsfV1cWiRXP9Zsc8v4b6oh+dCjcUS4br3cyYH0ipfPvNJH/nbWfwWYb33fHxEu9rxFdYFmVznR8wQ53kOFXHrn/8qjoUbk4ds47t0cLFQ3zsTh/rqfeH4LwAs1SjDo5sj/GJSZqcIBoZkSb+2akKxVKVIXEdxkZtp8j8ziyFn7k8jm3zMhRU683BQYOWLz+eL+Atnn9pccVX8n3Kr4PDwQ0ffyXLpc89vpuuOJCsDs5Yll41t0RfDt5W1PYAx3FKaifevHmLXxOdSYsoCW6ktYMRacvb6QGOLQ/GUQGOfM+ZCnA8IzPjYJTllq/pk2rqkyvM2H4zyd952xkSeJo0elSCaQQ4fUPvH8HxG65kLAHOsuXzpioZ6xLVxJmmQIBTnxrHrxyfExMTxDEOjQwblMlyUNBlAY4mgc7YWEHlc6eeeoL6LCsVdvqUDnBsDm4KGZe+uIUDnITPwGE1m4iLtgc4ExNjquQmx5F5Pl9SJTcq47f8J0byLhx809cLAY5ufiisKgcEJFFr/cnfeX3HlODkBvr36qlv1Ahwoq2oUgU3Svl5AtKqfvxKHjA+kVclOMMjREOD0sLWJ8GOK221k+iAAEeMHRtXyyQPLDzxxBU0NMQL1ackb3T4X5GDm8mcR/dsShbg7Jxvja886qzm0as2ERctD3DkSY+OLqrhk8PGjZumSm7821ID6iTjev5Jx+RgQHYIrWMCHMEnSpnQoYNj0wGO1Irm7+pZngpwTMM/ucpwKMApBk9ehkaKOckE28+1TQ5wXJo3f0CVFDbsxAedKSbAkdtURw5PcoDDmeYcUx274XOMiL6H9pB6LRMTDpX4NCmPwJk7dzrA6bYSHOHYnJlP8r7H2VsmY9App5we/IXH5H2un1pZTQc4Nq0ujdOX1x+iZYXay/+z04cfvWzjxNnB26oSBzj3L8rQmhUc4FB5gGMmfva1v4PJbSd5ErGcQDZt2qZOLlIRS92SyuUiAUgouAl20GiA4ugIIqoVGZcEOJwOHjyqnqlh8Vv1IGPoaHJilGfgyGPYM3z14N++0qqfoKInOARIbRbdHpXo7cTft/kgnZwoUamE47XTyTk1nMUs4AsTnSdUDUIrHpfRzxPuP0nE7ovln1lWlkrFkup+oliUAHuQVq1axfuhrSon91tg7XKAU8gW6Y7H99DVh5IdjL84bfAnz9mUvyJ4W1XLAxzTlP6kPNq1azuNjRWD1lKc2eTk3ir/1lQJjf/9zg5wZB1k6NjopGoCKLfZrGB9zIzsw9IeVPUelL02fi2Vf1+eQmplLNXKQT1uwORLQwQ43WuWAY5kMlLYmrFyyX+jonrH71fVjx2X5Li0yzKo4dDDOTsuwBEz9qXy91KpWB4SWOToRhrW5CeJ5i8cppNOPpkmxsfVRX4/8QOcPN2xdS9dfXD6VnE1vzkp98WLthZfG7ytquUBjijyht26fYcalh02m83xhvebVU8HMP73OzrAYVLkbduuKiqVeU3WVUTag6reg7LXxq+l8u9LRVJ5/o2/X8nvyrNIkx1YCgKczpIywPFvUcm5xn8vFVb9nsTrUHeA1KdqHjvV/95tAY66nRb8Xc5BUn/o4OFxNUvHr1pKc+fOVaXL/XSryvMcmhzI06elBOdgrf3B98hxmfecs9d+f/C2qpYHOPL04U2bNnH0KiUeHOAMz/VLdXhBpX7N9E7rf7+TAxx9/1f6HJH5lI2l6wjFBzg1DqZ6M8+eH7/W70XGj5Hl/c/lfUa2nWwvvQ0Tq3cZobGi26OSYDvpenDqvMKfqf1gqtQ4YCQMeIP6iFCnSuub129Gun4PkRLYsIpBTsXjMvp5wv0nqRn7Y/n7UsmhjCVPP7bIMi06OjZO48cKarbOOPs0lZdIgDNdT7XKMvYA6btxYqhAd2zZnTjASdpEXLQ0wJE+Ofbt20f7dh2l7DDR4kULyJYbkVP4O+FbPLyT6veV6BOWNvW2aRlPtd9NMs1aB5T+e/S39Pt+H7+WWr8vKk1D1Bi/3QEOph8MpBQ7n5XmPc0yzXJ+IFBtXcu65TSbbT5je+v3dW6vWvt/1XmVv5lTpcjC4QD72LEx9fy3hUtH6IQTTlL1cSTAkeBbgpteDXCksMLgi8xjI3n64taD9Jy9iRvcJGoiLurc2slJKY080E+eUixdzHMAS0V58tGUyI7S6hMnAPQRnF+g1WSfk0eITAcsErxI3U0jK49MkYrHfp7olzD7qWepPN6l8yZKdF7CTjalF3F++bX/rraWBTiyIaVX8PyYpyoWSyVPW5oxJCQlM5HCGkV/XunvjedH4bNL0iQg7vMgqVZZ8hoMl/1d3vf7+OH3cSnJdzjpacT9DfpE3LZPmxLub0hVUo11qI7TXuNn7JLkwl8a2UieWBgnvwAg4DqOumXVy+QO0HW7jtHCYrJA7tHl5jbO5ms+/0Zr2dqT0huJUIW0/5eAp/cqUgUHZcXEpjLwmKTIazA8428s/Fk0KfIaDM/4Gwt/Fk2KvAbDM/7Gwp9FkyKvwfCMv7HwZ9GkyGswXPFvlRILj1MpaeFhgCmyXyRJLLpvIaVPsetWUn8Il+JIgCOlOL16WypOmkXliCFRL+Ja0/eiEelAhEmUKqVv0vXGyOAwDQ8O8euQ2pDhpMn9OUnRkplwaU348xlmHERNEp1OrVSTju4jMH6ylFbcb1RLzRY3zXBqtrhphlOzxU0znJKS4u9wqiRuGmlSdDrtTh2P11k0iIlbrzpFxS1zXJpBfivm96Li5iGcaombF51iSJ4n/VL5fVMRjR3J0zDnmfJAXHnt6VtUzDOll8bky5hxve3BYCIJtlh9SsFtKGnz7zlya8rfqLZtq+cBAAAA9IfyPC98Ua/wW3kejuj14EbWhC2LL02dElqQsBdxrekBjgQ20uxtfHyCt5hfkqOeARDUEk+6EaU1eFwCAKhNTqcxF1QJrrIB6lZW+hPez2Q49MR+i+jo6GGyHUfVwellkn9LT+LRGK+aU/fTxmAwkaYHOFKRWDaeBDoS4MiwPOBIBzYzIlgAAIA+IBf60TsZUhgg+WP4WTi9SFUzySZ/0IyYU6LEbclF0wMcSx6Fzo4eHVOv6mF4pjdVO1zXtdGpaaL3UnUC6Gbt3p9bPn2ZRjgllfb70JMavb/W+XvygFtpbGMYJs2bN0d6pqCJ8VLPt54S6i5MLkOZhMu6a74p9+4SNxEXLVqL5ZORUhtdctNPvacCAABolappSP4oFY17mZTgnDWep3OOJHsGzuYlma08SuIm4qLpa1Dq26iNWKG0TXWwCQAA0KfCVTUk3okLenqNlOBcv+0QLSokuxXnkbsvGEys5SEi6twAAABonMFPVXQPPmK9n1cGy5xQxqVUTcRF4r6oHliUpTWrVpIZdDCn+6Kq3cLL/966tY+RZxMtWDJE2aw85M8I6tyEapAzHXElX+w61dtyohH3cQGgufRxjuMVOoijs1/ZP3nfPLh3UmWCZ551OmUsq6crGhdyebrj8T101cFkPRo8cpz13nP2Ou8L3ibSmqM9VAHLNK2pejdVH9QHAADQ61TwzUlewyU4PV/RWAo3EpWvKCcdclI9A0c0fQ1KHZtwRSrpUTwMt6wAAAACnFX2Qx0cKeDg6CB4V1vaJuKi5SGi6/b+hgMAAEhNX+97Zs9f/J89kadzR5M9zHDvXMrzS6om4qLlAY5JbtXn3gSFdZ1L324L3XYDgA6H4xU6kNzhmEryGGPJEzkD9Fse+5/3qmt3j9OyZC3E6dHjjdRNxEV7jnh9zxEAAKCfqcA7mhX3fjCe5vYUf3VvMJRKG9ZiOLBpYZCjKnDFJOgPcdteEvSHuG0vCQCayA/e1FOL6yiMmk0TcdH7YSIAAAB0gOmAJ00v4vMnKXULKtHwACdcv0aSLIN+banZXqnpe/WVUrtFlyttqlfcb6ZJrdKu6UJnwPbvTNHtkjZBF5Lt5nIM4JLpSk0jizwrk6p+0ckHvVS9iGutybHVzhmOcLCzAgAA9IUgv5eAw/EMKnEMYKW4ZTWvoFpRpdaCAKc8kJGexKX5m0pStNMyMh9xCfpDhe2trwx1gh4l2zZm+2L7AzSN5PDqdpS++2GZHODYNGFPUCZhhLN7njnJL6mbiIsOuOcCAAAAPWfGRYNLrmHTk9wCnZuwF/H1y6xtHAqlbiIuWh7g6Lo5OrWOLGpcSqve8aE9sL36G7Y/QHtI/Rv/6HNdlxzLpjfvPESLE/YizmPuCQZSwxEPAAAADSW3ptxwCY5nkuc5/OKRlaJ0I+N424LB1Foe4Ph1b4I6OClqUQMAAEB3kT6nVPmpjmksDnBSZP3z886WYDA1lOAAAABAg0npTZCCSsYS5BiGkyrAOfFg+l7ENbk1lqis6P5FGVqzYiVHRJZ67xnBDIf7d2fRkicppXF5+dauXU8evx5//JBf8ShYYDf4neaTFR0HMV5/0Nsf27s/YftD55nKLoM8cd+ecbWrrj5rNQ0MZMl2imU9i3fTXQ+Pl8k1bbVclpuhjCvHnkvHhkfpc1v30WX7EoUe4mW81PcFw6ngaAcAAIAmMtWtKnHOeIEuOJIsuNk3Z3a9iGsIcAAAAKCpJMDxTIOu3zNJC5P3Ij7rJuICAQ4AAAA0lO6mSYIMKa+R5KSMODxjdr2Iay0IcKKTCL9vweSnyLTiEvQHbO/+hu0P0E5SguNK7eIUHVNmnNn1Iq615oiXCsXqec0BVcEYJxsAAIDe5qpK1BICOBLkBJ8mMS9Pm4PBWWl5lKGiuFACAACA3iOFNeECG8+Mtruu7pT93oZgcFZQjAIAAABNo4IcKcUxCzyQvAxnXpGko81Zk+kmuiGW9Dk4UaZhkeMQrVv7GC+XR8etGOFPZQH98fE0YwAA6EeufsZNDz4HRxbE1MGMl6GSZdNq5xB9YcMoLS7UDjukF/HjR93TeYnRigoAAAA6k2e6dP3O8UTBjdiwLLe1nuBGtDzAkUWTykbqVX0CAAAAvWaqrq081ZiTk+L2FEcIdTURFyjBAQAAgKaRsCbckDqJjEM7gsFZa0OAE54k4isAAIBepO7WBKlkuTSQyQR/qW1u3qmribhocoTBPz/1zJvQpPAcHAAAgJ4npTdym0qegcOZv/wvkVWHZt+LuIYoAwAAAJrK5mgjzW2qhQVvIhicNQQ4AAAA0BT+/RqXnnwsT086Yvsf1lBvL+Ja4gDHkH8mJ6M81RIeRx6Zo4bDnwEAAPQhlRfq/JBfo8wgn+zm/FIetpfjuObGncdocTHZLapHj7fq6kVcQwkOAAAANJjckzKnehRPwzVoXzBYFwQ4AAAA0HBSeqMrGBspKhhnXK+uXsQ1BDgAAADQNCUrXQXjuXmv7ibiouUBjkR04QQAAAC9RwIM9Qwc06Wc5fdjmcQpB7y6m4iLJgc4qgV8KGk8LJ2LAQAAQE9zONJIc4tqQZ7GgsG6tLYER4qowoENghwAAICeJDm8ZxrkWsnDm93zrEl+qbuJuGj5LSoAAADofZ7nkWO6dM7kGJ03Wgw+rW59A3oR16QaTKLA6oFFWVqzaiWZnn8fzTP82Chaj0aahIWZZoZch2jt2sfI9VxavmIOfypxnT++GfwOAABAP5nuXdvPE/fuGlODq89aTQMDWXLdEuebScs+Oo/0H+4MlujWTbvp6kPJHvL389MHf3Hpxvxzgrd1aU10IbeiZtyaCr0HAACAniIlOK4Zyf9ryDpe3b2Ia80PcGYsWPg9ghwAAICepZ7UHAwnMKfgbgkG64b7QwAAANBwUoVFijHS3GQ74aC9IRisW8sDHPXY5lACAACA3iN5/HljRbpwNHlm34hexDWU4AAAAEBDSQ/iBqc37xylRQk72TwwQgV+aUgTcYEABwAAAJokeenNwysb04u4hgAHAAAAmiJF/WIOhby9wWBDJA5weMLkuZy88lTL9DjyhhdW1ajmJK+cAAAA+pFhmEGSfHFmdizPwEmT33YS1UZaVbRN3lracr2dwWBDoAQHAAAAGs6VMowUvWrPzzemk00NAQ4AAAA0lPQiblvpnnV38kHaGAw2BAIcAAAAaCgpvfEyZooqxkQLJhvTi7jWggAnMomgLyslPAwAAAA9QZXgZE0azCTL5/fMNfP80rAm4qI1JTgeT0aWdoq8R3ADAADQi6QEp8TZvJmwn4b1SzMNbSIuWhPghEicI0VW6jXZcgMAAEAXkfz9iaOTdN6hYvBJDYazLxhqmJYHOAAAANDrXHr7jiO0oJisonHGpYb1Iq5JA65EdYDuX5ShNStWckTk31rygjb7Zo3H+JiGRY5DtG7tY7y8Hq1YNY8/5Q+C35H2/wAAAP3Gmcp+/Txxz85RiQto9VmraWAgS7ZTLHv+TTfll4Vcnu7YupeuOpCsBOfB4zMfvGC3/c7gbUOgBAcAAAAayjVcDjCSNxNfddhp6DNwRMsDHKl4FE4AAADQW1T+niKPX5z3jgWDDYMSHAAAAGioc8fzdM6oHbyr7sAIyX2shjYRF60PcKTJuBYeBgAA6CfR/DBRjdhOJMtRnp9ft3uMjisEb2p45Hij4U3ERZMjDNlgesHDk7IQ3AAAAKi8MPxcOM7qY/PN3uWa1PAm4gJRBgAAAMyaFDzpwifVgXhKpkvbg8GGQoADAAAAsyIP9NONhiyTQwrD5WSTIa8JzZ00tgSDDZU4wDHkn8nJKE+1hMeRkrep9/LKCQAAoB/pfFDniVHSzYHOP1Ue2nH8IEb3TJDLZMn0XDK8ScrIU/YSOumQuyEYbCiU4AAAAMCs+Y98McnjKMdxXXIzHmVS3KtaPEkNbyIuEOAAAABAavr2lDZZLFDJc8jLWWTK7aoE9s0xpK1Vw5uICwQ4AAAAUAc/lHBsm0qGR6sni3Tu4WTPwFm73GpKE3HR8gBHSq3CCQAAALqLX3pjksdhhOTlUuXGkD4qMyZdt/MYLSwmzOA9oylNxAVKcAAAACC16C0qw+KAJ2Nw0JO89CLbhF7ENQQ4AAAAUDfXdMm1QhFPAiN52hwMNhwCHAAAAJgVfXtKmodbLr8aDqV5Bs6qw/bGYLDhZL4SlSU9sChLa1at5IXwHyntyb02Fm3qXl6vxiTTzJDrEK1d+xi5rk0rT1jIn8vCB+NHfwAAAKAPcCgQDPl54u7th9V9n9VnnkEDg1nOM0vkepyBdqCp21NBtxJZ11XpFDpEn914iBYlrYND9DL+mfuC4YZCCQ4AAACk4pfaSBDhqtIbISU4b9o9lji4aVYv4hoCHAAAAEgtegPGMV1VspPUH1ZY2/nrTWkiLhDgAAAAtI2UfvglID6OGlQdlujnnUkHOS7Ps63eJJ9n1/Ca1kRctDzAkeUPp9bz7xdOp1bD9DF9TB/Tx/QxfUnhYICHU5R+dAJ1iyrIxx1enJIsUgpWE5uIi5SzAwAAAP0uHNwIuTUlTzFOU24xp4lNxAUCHAAAAJiVcJBDpkFuihDnxMPN6UVcQ4ADAAAAqek7avIqVU5cw05V92TpOI0Gg02ROMCRgifP5eSVp1qi3zUNoyw1nixStdRscdMMp2aLm2Y4NVvcNMOp2eKmGU7NFjfNcGq2uGmGU7PFTTOcmi1umuHUbHHTDKdmi5tmODVb3DTDqdniphlOzRY3zXAqZ3AeqJLpv0a5ofxT56GdxSWLZyvj8tJ5LpnWJM3L+M/Kq2XvHDPPL01rIi5mrnEAAACAGvTtKVVoY7j0xMk8nX245H9Yw2PHZZvaRFwgwAEAAIDUwh1tSnuw63bnE/ci7hm0PxhsGgQ4AAAAMCsS5EgLKmkmnkbG8bYHg02DAEetgnBqNUwf08f0MX1Mvy+mL/026dQD9FOLJciRpxjbZc/1qW5OwXs8GGya3ljLAAAA0HKe4XKA46oSnAEzWQVjseqw3dQm4qLlAY5EeuHUfhJxhlOrYfqYPqaP6WP6fTF96YJBpy6n83Cbk7pFxSljJQ8pFk96TW0iLlCCAwAAAKnpME1aUZ0/PkFPOiydg9d2cLi5vYhrDQ9wwqUzkqQ+tXrl1BqyyqulZoubZjg1W9w0w6nZ4qYZTs0WN81wara4aYZTs8VNM5yaLW6a4dRscdMMp2aLm2Y4NVvcNMOp2eKmGU7NFjfNcGq2uGmGU2/R+XrGc+mGXaO0oJhsGR9emWl6E3GBEhwAAACYFQlypBDDL85IxjW9pjcRF31YB6fdETWmj+lj+pg+po/pt2P6jSf5uLSgMlIEOJbj7AwGmwolOAAAAJCaGVQqlgrGlKLrpZFic3sR1xDgAAAAtEU4C+bh5IUgHSGbyapbVEXTIzNFNHHSQXosGGwqBDgAAABtI4FNOCvmiEG916lzSQegtuuSaxqUSVGC0+xexLXOXnsAAADQkQqFAtmeQ27OIjNhEc7eOWaBX5reRFwgwAEAAIDUSqUSeR4nK0+W6lK8trXHDWwzWtBEXMgsJZqrBxZlac2qlWR6/qOYPcOPjaovk8n/LHJconVrHyNyHVpx0iL+XGqP++NbUhwHAADQZ6afD+fniTu2HlQfrj7zDBoYzJLrlsj1HP8rHcjjectb43S2MU5fWTtB8wu1W4b9/LSB31y6qXBR8LapUIIDAAAAs2JaBt2wPVlwIyyXdgSDTYcABwAAAFIzPb/sKc19mDkFd0sw2HQIcAAAACAllwyOcNRtthQRzoqjze9FXGt5gCMRXzgBAABAd5HgQdWhNVzKBHVyk1g27h0NBpsOJTgAAACQmhTcnDth0/lHk1WEblUv4hoCHAAAAEhNWlFdt2ecFheT3Y55eIW1g4OiljQRFwhwAAAAIDV5knGK6jfSMWdLehHXGh7gROvYyMKrV04AAADQGyRb97xkzcOF5XktayIuUIIDAAAAqUhYUzI8DnKSl16MFLyWNREXCHAAAAAgFWke7pgupehjk1Ye8TYGgy2BAAcAAABScziCSFP75PhjdCgYbImWBzjROjoAAADQXVyDyLYMGsxkgk+q2zfHaFkv4hpKcAAAACAVuUXlZk2yzGT3qB5blmlpE3HR8gDHMAwyeIWo1zQ37wAAAKAjSAlO0XMpYyYLIzyztU3EBUpwAAAAIBXPcOn8/CQ98eBk8El1hte6XsQ1eTxNopow9y/K0JoVKzkistR7L+h7wow85qe8Xg3/1bDIdYjWrn1MPfXwpBMX81RdFc4JL2HxFgAAQC9xp7JfaXRt0o6tByVzpdVnnkEDg1ly3RK5nG92osmBPN2+ZTc996AdfFLd/1uV/een7Cj9dfC2JVCCAwAAAKm4hssBRPKWQiuO2i1tIi5aHuDoujeogwMAANCdpJKxl6KLguXHvJY2ERcowQEAAIBUzh3P0wWjybppODhMJX5paRNxgQAHAAAAUrlu1xgdVwze1PDwSqPlTcQFAhwAAABIJU0Fk1b3Iq61PMCRtvPhBAAAAJ3Lr28TvJkF06OdwWBLoQQHAAAAEnMl2kkR8IwUaXMw2FIND3CiJTRSx1q9cgIAAIDuIw2mDM7MTc8kxzQ5yElWwVisOuRtCAZbCiU4AAAAUJNFBjnkkZchylrJw4dW9yKutTzAUffy9CtKdQAAADqSdMcwnU+bZGZNcjnEcS2Hsgl7Idg3x2x5L+IaSnAAAACggumuleThvK5h0xPzx+i8o8naiK87LrOTQ6GWNxEXCHAAAACgIimrkX4mDcMjx7Lp+p2HaHEhWR0cg4x9wWDLIcABAACAMnJ7SvqbCrMd2/8s8nk1ptueJuICAQ4AAEAbdEPfjKpTTc8vwXFshxyyUwUOgyX38WCw5RDgAAAAQG2uNBHigCdFJ5vHj9qbgsGWSxzgcHxJhlkebXZqxAkAAACNIc+yE5lMlkyOGiwz+S2qFaPegWCw5VCCAwAAAFVJkGOaFp2fd+mCI8lKcA61qRdxDQEOAABAG0zdDQnujnQuqXDsP7vu+t2TtDBhL+J/WGG0rYm4QIADAAAAZaSajQQIkvQDekvyTJwU9W9s02hLL+IaAhwAAACoSkpwJMDxVKiTjOl6O4LBtkCAAwAAADPowhp9e8rhlLx6MdFwkdrWRFx0TYCjisgSpHrF/WaaVK+430yT6hX3m2lSveJ+M02qV9xvpkn1ivvNNKlecb+ZJtUr7jfTpHrF/WaaVK+430yT6hX3m2lSveJ+M02qV9xvpkm9yA9y/If+2RwxpFnMlUfa04u4hhIcAAAAmEE/4M9yJViQ+jdSuzh5Gc7KUWpbE3EhwVlQCFXdA4uytGbVSl5YS733DD82ql7fyOR/Fjm8PtatfYzXi0MnnLKEP5cV5I9vVogHezUaBgCA/hTNL6fyOen6wDNp25b96sPVZ55BA4NZct0SuZ4TfKm1JKAxPTeYZ5Mc06aJ4XH6/JYj9Jx9tvpONftHzOLScfcUXkS0ogIAAIAOoXoQl0IKvwSHDJvOmZik844mC7jWLc/taGdwI7omwJFKTklSveJ+M02qV9xvpkn1ivvNNKlecb+ZJtUr7jfTpHrF/WaaVK+430yT6hX3m2lSveJ+M02qV9xvpkn1ivvNNKlecb+ZJtUr7jfTpF4ksY1nuuRwtHDtzgItLCS66SPro61NxAVKcAAAAKAiCXKkBVWaGsam175exDUEOAAAAFBGl0qpFmKqBEc62Qz+mMBIwWlrE3GBAAcAAABiqUCHX9M2EV9+1NkYDLZNywOccL8bnd33BgAAQP+SwEaX5EgdHM601edJrDjmtrWJuEAJDgAAAFQkz8I5f6xA543Wbh4uDg0bbe1FXEv8HJz7F2VozYqVHBGVPwen0nNsfDOfg3PiqUt5qvyBaoImASFiLAAA6D+eJ2UkrAOfg+NzOQ93Keu69KFdu+i5hyVuqe2nT8g+fvmG0inB27ZBdAEAAAA1JGseLjzDa3sTcdHyAEdKbPz6N/KK+AoAAKCTSf2bNDVmTc/eHQy2FSIMAAAAiCUVjEscKaTpPmm4YGwOBtsKAQ4AAADMIAGCBDYly6Wc5de/TWLlEVofDLYVAhwAAACoSDURT1EHZ+Wo1/Ym4gIBDgAAAMwgbbw80/C7aUjowIhZ5Je2NxEXCHAAAABgBrk9JV00nDM5TueOJmsi/sjy3E4era29iGsIcAAAAKCMBDeuYfKrSzfuPEpLE/YizuN1RBNx0aIARwq6ggcaAQAAQFdwOcAx5EGECZmu1xGlNwIlOAAAABCDAxvTU03FkxoueW3vRVxrcoDDK2dG5IeYCgAAoNN5XrrgRqw4UuqIJuKizdEGgh0AAIBOIx1VGq5H542X6BnHgg8TWHGsM5qIi9ZEGKoUJ1RByZMHBiG4AQAA6Fgc4Fy7c5QWFpNVMD4yRNLdeEc0EReIMgAAANpA98mo+2fsNJbqgSr5A/4eXGns6pQm4gIBDgAAAET4dWjTVMFxTGNvMNgR5DZbovDsgUVZWrNqJZnq9hKPFESb8gPVmIZFjuvSukfXq/V10mnH86dyyyoY30weHTZDvZNPWwGr02D5g4FZwvIHA10Kyx8MzBKWPxiYJc8NZsBwVNWNrZs5PvAMWn3mGTQwmCXXLZHr8d/agoMbnvZHd+2k5x5K9pC/nz3B+M5lG7wXBm/bDiU4AAAAMIMEcEaKW1RDReqYJuKidQFOWaSLuAoAAPqbYRp+kno4/NpJVDcNGWkmnjzAWXG0M3oR19oQaSC4AQAA6GRSeuNK8BW8T+KEI96+YLAjtCDakPo2kirweBYkBeSeaL33RavRv9/s6cxWs+crvOzNnM5sNXu+wsvezOnMVrPnK7zszZzObDV7vsLL3szpzFaz5yu87M2czmw1e77Cy97M6cxaB82TlODYOZMGrUzwSXWHhg2pqNMxTcRFa4tTVChYJdgBAACAtpMSnBJHCKaRrAzn0eVmx/QirrU2wKkmUpIDAAAA7SElOE88Nk7nHS4Gn1TnmmbH9CKuIaIAAACACJfevuMoLSgmu+tiukZHld6Ilgc40Xug0aRV+ryW6HjR1G5x8xROWqXPa4mOF03tFjdP4aRV+ryW6HjR1G5x8xROWqXPa4mOF03tFjdP4aRV+ryW6HjR1G5x8xROWqXPa4mOF03tFjdP4aRV+ryW6HjR1G5x8xROmmTM8l4aMIU/bynDJlOez5PQUJG2BIMdAyU4AAAAHUMimk6oq+pygJM8ulp6zNkUDHYMBDgAAABQJu1D/k486nRUNw0CAQ4AAEC7zWhkU+MRK0129kSezjmW7BbVoeHO6kVc67gAR0rEUpSKpaZ/v9nTAQAAqCoS1LQ+Qw4HUOVTv3b3OC0rBG9qeHiF1VG9iGsdF+AAAAD0DRXkRLPiVpfczAwFjBQlALZJHfUEY63lAY5hSL8b0wkAAABaLxoAhFtzmSk6aTBd2h0MdpSWBzgAAADQGSSg0WU1KsCR1lMkTcSTlyINl7yO6kVck0IovWxV3b8oQ2tWrOQFt9R7z/Bjo1pRnjzm2XFdWrd2oyp1O/m0VcFffFYHdL4htcU7hY6emzVP+ve1Tlp2geX3X7H8/mujYfmDgQCWPxhoE8MNZkiCCc+kzZt3qRz5zDNPp+zgALluiVzP/448WTiq3nqkEszIdJ0gP89yXm0YRf6oSB/duZ+uOih1h2vbupBuPPkwfTJ42zH6vgSn0w5wAADob9F8KS64aaTo9FzD5kAneQnOSYdpTzDYUVoe4KAODgAAQMiMJuKtEw5u1KBpUMkishLmz53Yi7jW9yU4AAAA7df67DiujEYCnoLpJa488ofjMx3ZRFwgwOkwsnNFiwsbSf9+s6czW82er/CyN3M6s9Xs+QovezOnM1vNnq/wsjdzOrPV7PkKL3szpzNbzZ6v8LI3czrJSRYckw3L7aEUt4jqIbe/TM8kg5MENSWenTPzJXrKaLIQxyOjI5uICwQ4AAAAfSiubo9junTjzgmaX0gWYJleZzYRF20PcBodUUd/r1YCAABor/ZmxYZqGu6XGknd2DSVmoeK7tZgsOOgBAcAAKDPqSb0rkeeKS2okvVBJZaNueuDwY7TcQFOXClLmgQAAABpueq5POeP5+mZx5I/YOfEI27H9SKuoQQHAACgj6nCGykk8Gx6w+5jtLCYLMA5MtiZvYhrbQ9w5F5fONUr+nu1EgAAQD+a8SRkU/omSF568+DKzuxFXEMJDgAAQJ/SQY5fzcPl8CZ5gGNbxv5gsCMhwAEAAOhD8vwbIcGNNA+3LTdFH+IyvtexTcQFAhwAAIAO0M7ei6TKhs0RgZuiB8+hYmf2Iq4hwAEAAOgUQZDjua15krE0D/d7WedwwB9I7LhRb0Mw2JEQ4AAAAHSA9pTgSBjghwJyqypNJeNTDnf2LSopjEq0NA8sytKaVSs5wLPUe8/wV0it0iyTt5jDkei6tRt57fEKOX1l8Bef/L0uafvraGOvrUq9/Ytg/uuD+Q8G2gTzHwy0CeY/GOgMbqQp7+NbtpPHi7j6zFMpl82S53n8nXSlKmnokht5jrFj2WRnJuhzOw7Qc/YW1N+rkV7EF014J/MSoBUVAAAAVNbOOjjCsTyyEs7EI8cPdHQTcYEABwAAoFME8YWU3jSb3zTcL8mRwqQnThTorCPF4K/VuUQd3URcIMABAADoAEHNj5ZTzcQ53bRrnBbnk90GtDq4F3ENAQ4AAEAbSB3UcJL6N1IrVlpQSd2bZta/EeqJ/sGrNBG/f+lA4irGg6XO7UVcQ4ADAADQZySo0beodAnO8w5OJn7Q37JRu6ObiIvuC3CkFn04AQAAwKz4pTgmOSmjgROPurhFBQAAAB3KkO4ZbLpg/BhdMFoKPqzuaIf3Iq61IcCRSSZJEbrERp5jUE9qt7h5SpPaLW6e0qR2i5unNKnd4uYpTWq3uHlKk9otbp7SpHaLm6c0qd3i5ilNgggObjyXsl6Rbtx1iI4rJquB8/tV1m6jw5uIC2xxAACAPiWP+VOBjnpNxjE7v4m46L4AR5fk6AQAAACzIrlo2vo3nd6LuIYSHAAAgD4j3SzpvjVVgJO0+RQb7oIm4qL7AhzcUwUAAGgIaUVV4qw0aybPT5eO0vpgsKMhQgAAAOhT6hk4EgmkKME55VDnVzAWLQ9wok9uBAAAgDYxDSoaXuInGB8YMaUtecc3ERcowQEAAOhbLrlmMXErqkeWD3Z8L+Ja4gBH4jvP5eSVp/RkJU4nw/QqJKM8GZafop8jIbUxAcDsxR1T/ZTaTSoaS2Dz5PFj9NTRZAEOj9AVTcQFSnAAAAD6lktrdk7S/EKyAMd0u6OJuGh5gNNpESwAAEA/UgEA58Ne4ho4RIO2ty0Y7HgowQEAAOhDUu1EvaYoa1h6zN4YDHa87gtw8Bwc6CDREkkkJKTkCdrHlNhG6tVaHs3JDvgfJnDCYRu3qAAAAKBDGdLRpk3njBfp3EPF4MPqjg4ZtkX0y+Btx+u+AAd9UQEAANTN4hDgTTtHaWExWV764PHGHqNLmogLlOAAAAD0IXmKMaWoYGyb3oFgsCu0PsCJ1qGplGaQCBMlNgAAAI3hJn7AnzAsq2tKb0RcJAEAAAA9TMIa1QdVmibiRbsrehHXujDA0SU5OkFfiyv9S5MA6hG3T6VJAG0iTcPtjJeqifhxx6hrmogLHGEAAAB9SEpw0jTWP+kA7QwGu0IXBjgyy+HUXQzDQJpNImvqqtdzjenkuFWT45Yn/Xua6l+NkyPflXGC90kT9KepfSDok0/vX+F++iTJc0bikuoDiJMW3s/Lkv68R5JtOyrpZYb2kQrGtmXQYIbPrQkcGu6uJuKi+yKErlN+Ky16AkRKnzQ5QZqmSRYfoJKy2WzN5HImJEkzLVOlqb+b0rMubn1CdWq/MTNq/5Ok9x+obnBwkAYGBnidWWXHMrSe3JpyMhxGG8nCgEePz3VNL+KaXEQk2svuX5ShNStWckTkR3tesFJMvs6oxuRMSK5s1q3dqPL501efFPylhhnPuNHvuykm48wyWA7Ogvn/vLbU2uZlQGiZkpTc+OuyLNDx2zlWPFlG9yLHsdWrf/XIV9zBxYtcL3u8rYys/3umxxlXgjoSuArtU2rfMGl8YsJ/H8hY5VfD4VKaMFPOn7y/6fPD9H4U3eeie3B3k+NUAsIMX5AYvA5c1y/N6VuRc8zmzVtJVskTVp9MmSYHzMWMS6fax+hr6w/QwgQdbf789Oz/XLqx9NTgbVdAgNNUUhrgZ6imK/OdoU3rt/v7dIaTn8cm3AI9pFZMoNeH3uT6ffhzvRsEn1WKM6IV6CzL/0AHRGoSejvIb1abt/B86OFu2h2hPnrfkG3PmZA6eUb3F71faNH3gfD+Kr9RMU7mz9U0on+v9P1OI8uv51+G80Rz5mZo0dLFNDQ8SI6tD/I+1cYAp5C16batu+jqg4Xgk+p+eXrm+5dstP8keNsVEgc4DyzK0ppVHOAEl7w6wKl0haLNCHDOOCH4i6+3r4AlwPEfgW16HNFw2rR+G53Di7wmuNBzeGeuUPjQV2Q30LuCw/vJp3j3eki94c+zBi1ZuFD9rVQo8vcsdQVsmJ7af3SyTIsyZkYN69PmyMiIeg0Kemju3Dn+gOLyzzv0+98+TLlFFp016dD1JX8+pLBIXiVpsp1UUm/UR2V/r0Z/L25bx3ykJPzpmur9/X6dP/278jsyLMeqDN8xSPQgH9ZyJ3Pp8qW0bPly3q6uuvUSPR8ePXpUver6OlL/Rl7l+7L/HR71/+7Te61PApujYxN8qU10Hud11/L0LT4u5GfMIF+U/TRun6plan/0X6bWVfR9WuFZkd/Qx5DMozquOW3kU+Gq01aSkTPJs2WM8ky+n0g9wrCWBji5PN26ZRc97xBPMIH/PjF7+9O2la4N3nYFBDhNVR7gGG6G1q/bRnfliF6XrOuPvvWz+UN02fgkHb9iOf3P//wPHb/kOBodH6W5w/NC+4y8Sqag38s+ye8lE4nsVvo8wqGQPxAw+d+dX7yD/uqNb6ZPc3DzV/JzAFV8d26GXszRzqnHn0a//q9f09x582ggO0jj+XEaHuDoh5Xvo5GdUZ1y9YnT3+F4j1Wv+r2Qods/cwfd8MY1PXPOuIPTW4MAx8xmODNPlP30rHYGOJMDE/TZx/fQFQeSBThbFmfedupB+2PB267Qv6Fzi0zV5VBFkVLqwCcuZKI1bRnPk8XB9D1330NLFy/jTyyaNzKfMw4JsP116Wcc4fe8YvmE4anohtc1f1cn/U8CmvA/yZRe98o30Hvf9j66lUf/CY8JUMnmAYP+3rZpcP58+tYP/o2W8L6Z5eBGDumRwZGp/VP2QTnOXQ62HdvhV77Y4eQ5NqdwhuLvuwbxBZB6nU6yt/7l699Ar37jq+iT/LVHc9FAqfvI+U9CGrngM/mCr99JIBxOrSSxVVYXBSZw4kG7q5qIi+RLB7NicGCjk5CS6Ug9RIj4Lz7vfdz26L3vfB9deslloQNf1qG++pUsRZrlOlNJNcGVE4XF6zsYR24HSMbC3yhL/tikbitIC6z3vPvddNGfv4nW8GcP90BGAs3xU97HNi+ZQ/fdcy+dufqJVCgV1V4pSYZ13S6hMy2/xZWfDIsDGU7+GOEkop+ZNGAN0Pvf/X6aOP00+nPLo905/d3uJEeWlPpbLgdwnPR5EVrvXOlF/IhfR7SW0UFyONvqqibiovv2LjkgwqmDycGbcTJ+4oPZr2hMlMOFS0VrB4neyOtnyZWX0rv+/p3qM7mS5Y0dJHWK5CSf8QmS/6oT5xz+n0JUBiMZS+SfJi059M/e8bnb6bTnvoj+gjOx/875fwfQfsW71yd53/zcHbfRc5/zR2QVOUDOTu8oMqyCGvkXBDf639TuK/cfwmkq1NZf8PdrndxSiU5acTLd/ZX76NGhAfqeVGTpYmoJeRFMm49Lh5N8AG1x7e5jtLCYbAM8uJK6qhdxTY4iaBI5eP0rFb5q08FYJAOGaYdzBv055xcblwzRXXd9zm9p1lRB5iLbJEh3f+FL5J71RHoDz8dDA/IdAKL/zhJdz/vEyz74TvrTq17AOTUfz3EtgBqcYeey0tLIoSecdCa9+23vp1t5H/2v5lbNaCo5zOQOsrQulSbykSoo0KFs0+iqXsS17gtwpPl4OHU0vwhWgpyp0iYc0BV90/Xo95NE9376q7TyxJXkqABEyErTKaLKn6qRfEjnRWo6kmlwmnPcXPrpD35Oh+eeQK/iC+zH/Dqj0MckoLiG94Wr3/bX9Hdr/oFy7nx15nSHU5w+Z7GP+hySej0L5w3Ru278P3T2n/wFvaFE9Ei3Bt+8Dgp84VLM2GRbNgc7nX4OB8GbretKb0T3BThdRl2tBCc2XK1U9r98wv7UMNFN195Ez7vsjynH/1KvrlmtXzkEpI6Of0WZHy/QkiWL6Rc//jVtGZpLL+coaPsANly/+g1nxnLL9KxXvYI+/L6P8KUs72Yc8EjjH7fRxTUR8vtFTqbU2ePAX9zy0U/SoRUn0Z9zXHCwC+vjyJEk50TpA8k25RYdNJO+BSjrXJLGexVvjOT7b65EXdWLuNb6I0RKMpKkGSTS765oXwKakmlSkU9Qfrf0EGc9BzevkWdNnXc+/cvH/kU95ZT4KtW0eaXJJleZSX1bX1Z/OEmeIXV7/BoS0y2sBkd4ZgYNOnH1CfRv//Zv9GjOoj9xPTocPOEY+scfOLB9Mwc3Sy6+ij73qc+rFkBq52Gy/2RUruG/nyK7SXhXmXovX+Qk0Uo4qR8MJz2CJGlJlfH3eymxGeR5OXEBff3rX6WH+MNvdOFD8lQdbJlt9VywLr7X1gUkuNFJ8iJJKrDh/U4quluG7GPJLDvmdVUv4pocUdAkcixLYCNJSgfCETRM+zlfFW87YS7d+4V7g0+YrDxJLVO+cQw+/1586cX0hTu+pB44+K3Wzgy02cYBk/684NHkyWfSN+/9Bg0Fz7eZEYM08ZjWk1JUREU0OjFGFz3rGfR/3vo3dBu//3033qqSZ6lJgCNLGHsxC82g8x+Ho8wi2WSkKME55SDtCAa7ShfuXRL+h1On8+cRwU28X/Ie+EmH6OMf/Bc687SzeD15fv9QcoEXXOBWv7WnI6FKqTopw5EkZEvZns2vMuRRxrDopS99Kd1880foFv5IKppC75Om2C9xXdqxcjH96Mf/TvMXzVMB75RUx3L5fij7VvhfLXKCDp+k586ZQ8WiTR/+8Ido2RVX0Wv5MwnGAGKF6zhxMCmFhnKOm3QnKWsm25GPDnZfL+Iajgxom4dyBl3Pe+Az3nQNveJlf0ZFp+AXpZoeOZzktXYW0FjyCH1XPUZfyM2rDL39pr+mZ9/wZnoDXy2j+Xhv28nBwvNMl9YOZuk//v0/aNXKEzg8cang5Um69fCDkmTBczkZr/69uVgqUS6XUbfL7vjUHbRl/nz6WZc3HYfm0LelVIeuHNxIgY260LYc8nIOZRLm/g+tyHRlE3HRhQGOzHI4QTca40uCj2Q91RT7rW99Gw3lhtTnjm1zUGFQfjKvUu2tLUdstZSc/L6U2sj0dQVSeZaJW/LoUzffRqdf/RK6hj/+HYKcnnSQA+7nl1xaZ2bop9//D3rS2efzXiDVz6VmMe+b/M8PbHSwIsM61eLvwbrEMFxymEY2M12MeMIJJ9ALX/hC+iTPSjc3HYfmiYa+qh4ZBzhnjR+h8w4n6/vDMYz9wWDXic8zAJpsDucVf835xvn8+twXXE2//d//Itt2KMcn8FLJoZGhIfX8oFbTD2gTcrVecotkSgVjnpWvfP4eWnTJxXQNHzV4Rk7v+Z7FATdv6rs+dRc965kXB5/6JDTxHzjZXnKLQY6PyYkCvePv3kG/v/NLdBMHN09IUWEU+o9UNBY2X0BKic5b9+RpfiEa/sTjvX53MNh1EOBA25xfIPoaH3jz1m2jZ115EX3/ge+pSw77WEk1ix0c4TN36BiUwWhqNnlOR7HoX+nYXom+9bV/paMrT6TXcEbzCJqP9wxpDv6xPNH73vW39Mo/e7lfqbdQUkGN/idlLvLPP21Kku2vU2tIcJPNWvS617+efnXHrXQnHyKv4+NoUcIn0kL/Uq2p7KIqiUxTwXio5G0LBrsOAhxoqzP45PxtDhTOsVx6+eteTnd98bM0NG9QNYklznDKS//ljetnLxzdSGoWPwszVC/RuZx/T2p47jAtXLyYfv3T39CuOcvozwoeHcniEOp2Unn8TbwvXfHma+nd7/0gFfkqV8UsQTNufUPJ1/qgJmz00Cg95/LL6NH7vkK35Uv0dL4WAKhF7a3Sktfm4MZNF+AsGnc2BYNdJ/HZWbrz91xOXnmqZapPluCcYHAYGU5TTySOphn8LKfbqPUUWm9tOi92tFM5UPjxUYM+fZToE697A73pLW9U66lo2nxAemRP+g8Es+XhOFJuI7udDAZvZW/RqX56P5MNJdVL5SGA/jOVpcKePDZ/yXFL6PsP3E8b5g3TpRxlIcjpXg9x7Pp63o9Of9Gf0j9/4hbeyB7lhobVK1n+wapLcPzSm7TkN/zf8en3OsnOHE7lbNelQoFnkGOu7et30XP/6Hl0+Kc/o7s49rpQ9v9uw4ss/b9JnmDitlrT6bOZnB0NTy4ODRoeGaTBhC2oxMkH3e3BYNfBmRk6wuKiR2/gOOaODNH//cJn6OqXXk2uxVcafCBmBuRK2qPCxBi/NiaMSUofIByeqlcjY6kSnac+6cl075fvpc25ufSNqVZX0E02DBr0at61hp/+dLrrri+Rw9tRWpmEU7tlTFNVLP75T39FT7/4aVRc+190FwdlT01WPxRAMfm8KUlaTp15ZIzOHU12zjo6aHRlL+IaAhzoKBfxlepteaI93/4hXXLJs+nokcNBTMOBTjarAg3H4EvXLAcc0nKkaXuw5HDy43zdHnoYmR6SWXrRn76APvCP/0i38oc/a9p8QDOsGyB6qeXR5KlPoO9+97u0YMECyvL+1Yl+8N0fqIr4S/fvpPuk5AbBDSQVeZCiZVr0hu1jtESeHJ/Ag6vMrm0iLnBaho4jQc4d/Fr47e/oqRc/hTZtWc9hTYmGssP+Fzi8cMwipxIPtb70pGj79wYcnqtrr72WrlzzVvrb4QH6lRQ0QcfbMWDQn+WIHs8spH/7xndo6dKl6vOJiQn12nrTjyWIuvWTt9GLX/n/0Wpngr6TM2i11EsDSEg9JDUIclSNEJP3tQRVSzTHpK7sRVzrvgBHNlY4QU96Jgc5X+aAYc7OLXTGhWfQr3/7KyrYk6R7jRodP8bf8u8hyF6gU0MFtyikvoA8G0eSkKbsMi15nzUs+ud/+iid95pX0/UDRP/V+pbtkNL9tkePlAboRz/4IZ25+kz1meu6NDw8HNQXnE6NI79V/nvh0FzqRkwUJqfCHJvD5xvfdhPd+bbr6JbJIv2U5+WkQvKMCUCTIEeCGyH1QbNWijOl63VtE3HR8DwBqml9aUM3O7dA9K8lg87h14uvupy+8e1vUrFU5NVo0qA5xAFGRmUM7SSVjqUH8k/d+ilacNklqufpX6Mkp2P9mNOtfBh+9Stfowsveqo6A0pwY5qtPxX6U5T9V3Ifg4YHhnnIo8NjR+jFL3kx/fwTt9Ad/Kc3cUIzcJiNcD0ywzOlgXjwLpnBLu1FXOu+AKdma6sO1C3z2YFO5qvW7/LrnRNEN//Fa+mDH/gQuRMeDWVH/HyhTQGO3BqTf1bOIiPH2RJfGf3oez+i/AVPoOuyeBBgJ/pPDj7fxunNd3yanvcnz1WftT64kZ02HKzIsH+LatLO076D++jiSy6hrf/6HbqDd20pyQSoV1mr8BSnzMUT3dmLuNZ9AU43UYGNDm6CgIx3rhQt9ICdkCe6hk/0/1Ii+u4//QO9/R1vISrxB8F6lMzBT9NruzX8qcqzckzXoizl6Aff/gHtOmERvYrndSMeBNgx5EF+11pEl61ZQ6973V+qwFRpdnBTFs/4A+H6NhMFv7ZnwS7Qo489Qk9+xpPIevhhuovnF8ENNJbUXZSdKhztVHfaAa9rm4iL1gc40To0ldIMrc++Gqdb57uzXMqr8RaH6Me33Uoveumf0uT4UbJJMgjpAbykhv0wJ/kBPFv6uSiGmqJLpmWq5xydfNyp9P2v30+b5g3TSxyP9uXi9mVoJRXcDBI94cUvpY/d/HHKZLKU4X+ypzR168huKPlJEKjoUj/9yAExYGVVdyC/+c1v6NmXP5uWbt1NX+DY66nd+Iwb6GAmBzcuGXNN3ueCj2o4PGTYlte9TcQFzr6tUlaaA7P1LA5wPs0Z1ubvP0CXXP0senznJt6JJcOSPqwKUxWBm07lUfJkifLpSdBz4QVPox/94Me0ac4wXeW6dAgPAmybRznAvMHMknnmU+iLn/9S8GmLTZU0yrNILHJd/8GVEvhYGYvu/upX6IrLLqczi3n6Cu8qT0rYhBcgKTldSWXjM8bG6LwjyYoGH12e6+om4qILz7w6UOiSgEHdluqC+ewiUnT/xQGi4n8/Qhc9/WL61c9/wZ8aNOBK/w6Sm0hqfimOTEKX5GhSp0M87cKn0W233k4P8bx+I/gMWmst7w5/5rlUPOuJ9B8/+QkNDsj+0UKyG8qjdTggl+dhizHnGGXNHO+8/IavpOU5Sre8/nWqMvE3J/0K9QCNJBWN1VPY+TR17Y7xxBXWbYu6thdxDZeW0JXkKvdu3ntXHDhEL/iTF9L93/++esaDTx/A/g2rVhofH1dBjssZ66tf+Wq6+WM3062ct/0SR1pLbZHghgOIPSuX0Te/9U0aGR5pw8lO9j+HpIsRKb0Rc6y56nWS/11347V037veRbeWiN7If5YuSwAaRQc2Ql5tPgDSPJ3b9GhPMNi1uvC0K7McTh2uYp0iqNd5eaJv87pdNXaEXvjyF9CdX/gMfyoZiUEeBxhTtwKaQU4UkiRP4mQE/+bMmaOen5LJZFSdnLff9Ha6/Kab6Dqerf/qzAfl9pydAwa9OOf3M/Vv//odesKpp1HWslSTfoO3STg1j//bcktKntt0+Nhh9b5AeTqY309Xv/gq+tUdt9PtHIQ9u4m7KYAENxLYSICTxkCxe3sR15DzthRWd6OdXHTpP7ImfSrv0qffdj3d+I4bqOTmyXZschyvI9b4zR++mU570YvojZyZ/S+ajzfdDzm4+D2/fuPeb9IzLni6/yHTFcFbabI4yWGvRYvnLlGhzo492+mZlz2Djv361/RZ3jmlThlAM4VvUaV5duXicaerm4gL5LjQ9ZaWXNVR560TRL/45G30yle/UtVzyBiVi0yknMe/aSCamOnxEeZlXbrnG1+h3DOeQq8pIMhpJikl+wS/3nnnnfSnV7/A/zCksU8nDpN9KLwfyXQMGsoOkVfgQJsvnx98+EG66OKLaOh3m+nuPPqUgtaRIMfxd8nETj7sdHUTcYEAp1Vwm6rpVAsrm2j9N75Fz3/h82liTHofL1ce2DSfXMGbVoay1oC6XeI+5Rz6Sz7JbMYzchruv3NEb+Lg8UXvfS+99mV/qdrWSRNsz3NUyU3zght/nyqRox5WEH7OjWQohmXQv9//Y7r8yitpxYED9JUM0TnoUwpaRB7yZ/EO6rf5TH72s1pfhbHhEue6qoaByYlPEuFUi5xY/CRv+Hd4iokS/688ZYIU/bzTk6wnK3iVs536D5rk6SWir/DrzvsfoGdd/GzavmkrX7oQjU8WqOD6R6yfAcntK50R1bFFZNRQkm3shdJAboinI43XDVq2YDl97Z6v0cFVJ9D/xwfEbjwjp2F+x8HNX3E659V/Qe/9m/eQUfTXbdbgD5mhHuXKJ3h+Dae6BT8h+5XD/+e9jIr872hpgooF/pT/u/v2L9PfvehP6IP7DtEPJ0wENyGGGXfO7KMky1+WZKX468WU/KIOsnubLp99eB8c5Au/pxwbpwuPJrsnumO+IVeHv/bfdS+cYZtKVm9oFasKx8EwNM3ZBaKv8lWz+4cH6aLLn0W/+93vaGRogAaCVlZ8GlFJnUkaTDK6OP4UTTp79RPpvq/cR48Pz6eriy4dyDV+HvqNdItxjUW0/MVX02233EbFok12waHSMT6Z8/GW5EKsESSIledZyx4m9W1yWZNufv9H6RM3vp5uNQr0Jv7LUt7mAK0ie34mCHKu3TVGCxM2Ed9wnLmZx+3qZ+AIBDjQk87iA/lrg0SLdu2ky59/Bf34Zz9VsWWGk9rp5U0bgk3bdekZT3s6ffnLX6ZHRgz6ltOGmeghD3Jw8xrezsVnn0F33fE5GrAGKZfLkJUxKDuXox7RzNvDQewk4asEN0OU439ZGiwO0F++5o305fe/nT5CRXpmsgtngKaQCsZpmogb5HR9E3GBAKdlsKpb7aw80b+bJp0yNkp//NLn0idvl+qnTC6iJYViC3nrtCDiUb2hs+c+93n0sQ9/nD7DERd6H5+dxziweQ2ftA+ctpK+e+/3aNHQYlVyUiiUyMxEVmqTghwvKJCRX5fgWRw+cIwuv+pK+p8vf4Y+xX+4PPgcoNVMPqX5LaiCHTWhnE1d30RcdGGuK7McTt2gC1dzj1hedOnnfOly29Ei3fuONfTOv34bkTxQjfO/sbHpg149nM9xVX2xekT3zmgaHhhUgZTcNrnp+pvoRX/3HnozX93/NMXVFRDtyZn0iizR1mXz6Dv3fJdWLFpJgzTAm9WkgYEsmRLfyKaUlS7rVq3f6NaQNHuy97icg0xyQCUFcSXejo9t2EIXXvRUOvKb/1BdiuAZN9BuEuCUeFfPWMmvpBYfMzYEg12tviMcoAssKHn0+hLRv+SJvvcvH6OX/sVLaWLUpjnD07u/KRX8WlRXQ6ocZ3h6Dv97x9+9g8599cvoLQN4EGAa37Fd2pJZQPff90O64AlPIqsUWXkt2JR+iGSQkTHJ4un99ne/pac860k0b+d6upNnB72BQ3vxHur5TSlKHNtIx8BJnX7A2xoMdrUuDHDUdVModYNumc/eJi2sPsMZ0YYffJOuuOoSOrr/aPAX/0CQoKNVQY6QSsfZTI4+/8XP0cJLLlZNnKWpM1Qn3V7caQ3QZz/xRXrKWU9TsYzUu1HkHC5JPqxrU+of0qmyAb4y/tZ3vkuXXHkxnXbwKH3V8fc1gHZTTzE2jVTPwDkyaDjd3ou41oUBTjcJB2H8qjreDN5CW0jG83nOryYf/g2deeFq+u3//FZ9Lk3H/cQnBLlNJXma3nRNIgefNCnOUo6+++3vknvG+fQG/vBhPCOnov/kOGYNB4Gv+uBH6SUveoG/rYr831h7IgrZUp+445P0kpe8gM7lGbk/Y9KZ6DATOohnuhzgFHhfTXZCe2jFQNf3Iq61IcCRSSZJUXIzu1tvaDc5p4RUpKPOL/MmWX50H/3pK15Iv/jVL6ng5dUtI9nzSlKZQnKuuN2woQwObYb4/xnKZQbolz/7JY2uOoVeWfDo4WzTJ951pDK21Fd63o3vohvffh15w0S5ebyeBvh1JOsHpbLdGhgfyl0m/05TEPTavOOEDufXv+ka+so73kqf4b/9uGjSCjQDhw7id9Ng0zn5I/SU0WT3TB2TDgSDXQ9n0VaR0hsEOh3jHA5y7uXjfcXhPfScKy+he795ryq/mbTzqrIxTfKX5EpcMrUmk0IIy8jSYG6Avv31b9GuhQvplSWXduSSVwrsdVI/SYKbC17+l/QP7/sAB6RFci254OGVJ080a2BQ4/MjpUyQ1Ht5yfApk//Lj5fouc+/mn5351308XFbdRWyBMENdCCP854bdo3TAnnwZAKW5+0OBrteFwY4clILpw6nbkvhxNeJzswTfXPMoE+XiG559evp3R94jypJkSd20xDvXVYzMs5pcntFeriWgEo6gszlcnTeky6g73/v+/QoT/gH/Dfwn3XzBt5Gy5//R3Tbpz9FXo6oaBT45CXRpz62mrCt5Oeni3CUyZJNW3dsp0svezbtfuCHdAefQdFSCjqZ1MNJc2xkS73RRFygBAf62qkFj/6K88hbCkT3v//D9NrXvYZs16Yi52pmZuZZQbLTcKqHBDiu56qkKzfbhSI9/aJn0t133023ZYl+1eeFOBLcvJoDiLFzTqW7v/hlygxkgrOWR8dolLdBeCtIRNJg8pOhOGrrzi30zCueTpMP/pY+i5ZS0OHURRTvwGke8rdkwt4UDHa9Lgxw5IwfTh1Odc+AOLLTXcIZ2B38+uDn76Y/esGVdHB0v7roUf0LBQ/ni6MDHZ3SkKbplmWppGUGcirIefkrXkEvee/76HrOz/u1+fjOAYNemyPatmQh/eQHP6XjlixVz7jh0zVl+d8wjQTfTLvmE5IdIFj3XsGl3/z6V/Tkp51FSx/fTV/iv6E3cOh0cvdWnp4+ZAWtDBM49aDTE03EBXLelsLq7mRyNX47xxpjP/lPetozn0bb9+9QAU42y0EHnyTUlVDkX70kyJEUZjsO5fOT9Df/52/omTdeS3/Fn/13nwU52zm4eb7j0ZY58+m73/oenXzCiWr9axLgWJThIyq87lJcpiYV3Am/+2t300VXXUyrjzn0bzzJ89FSCrqAlOBI6U2aM1Uv9CKuIccFCHkWZ2Z38cXO4s076KKnXkTrHlmnAhnXdThDjctAZ1N2U93g8BAHPZaqm3PLR26hVS//U7qG50t6zO4Hh3k5XzDs0UMjRF//6n307GdepD5Xlb+Z3++TBDfhktwmBDeCg953v+899NE3vpbuLBHdz7nFyegNHLqEdNVw3niRLjiS7F7qzvnGOL90fS/iGgKcVsFtqq7x1CLRlzgvXbRjO1307KfTt7/5Lcpl/CIUOWFI8jU2sFHPdHH9H5cgZyA7KKEVfevub9LwFRfR6/lz6Tm71/0rp99zzPK5z3yOLr3kObwGXCq5RbLkccGarCad6jFj/PIfvXHNDfS9j32AbuX84RpOx0s3HwBdwJS6fXzs3LhnlBYl7EX8seW90Yu4ljjXVQXyfPJVRV6hlJ6+4vWTYUi/PHHJiKRMkKKfd3hST8fl+VavvLp5leEU2fnO4yDn37MGnV6YoDdf8wa69dZb1f6utqkqQZAkJQeNI79dzqMMT0OqPH/rK18l+4ln0Kscos09/CDA/+UA7hZ5mN+1a+hVL3u1KqeR84Q1dRsvOHpkFejVMNsDisdzjnEIecwPVP2SeXmgvUOHDx+kSy+9hH7x+U/RrXzeu9j/CjRY2bkSye+8VfII6RtvVvlrOdORc1bynZe/2jNNxAWKFZpKAprwKpb3wSB0vOV8tf4jx6QPHD1Md954A73lphtUXF7I5ymfnwi+1WDSRH2KQZNOUXUiefySZXT/t79PO5csoRfxfG0eDL7SQ+QW3Gt5/V7wqtfSB//+Qyq4k6NHH0GmCkCEHETBgVRnrCcXU+Yck4qlPE2UxqhEedq4eyOd9eQz6fBvfkm3cwZxUbLSfYCuN2C7PdNEXIRzXwCIWFp06Y2cl36U029vv4Ne+uI/JduepOygvlcUzoIbb9Ac9EuKjAE64aRVdP/3H6CHh016cYboQK53SnIkuLmGV+Oq5/8x3XXr52goM0ilknS/4K9bvzJxeD2Hln22q4HHM+dycGPnKZcdpJHsPPrDuj/QU5/5ZDpu1wH6Av8dzcChmzmmy9dMya+qF054PdNEXDTvzAzlQiU5QTUL6CJXcrrZc2j9979Hf/QnV9GxiSM0XiwvxWnGwTQ5nqfJyTzZNEHSlcSFT34K/cPf/wM9xHn/D+RBhD3A5kDiY5wepQH60l13k+EZVJiwKZttdtMxj//ZVJLOrNh3HvhXevJTLqSTd0zSfbwxpUsPgG4lD/hzpcsXfk3qtH30eDDYE7owwJFZDqdu0IWrGWaQFlbyrJxjv/gfeurTz6eDB3ZxBskfNpFpmDQwMECOY1OGAwC5SXPXXXepriZeaPfGfpXhOO0mXrAz8wX61je/RcViiQaG9XM75OwcTrMlwSAn199eri1BjcO/yEGO6dHHbv8IvfAFL6bzeRI/sgxajZZSzYGLu5ZR/VDlLMqFnrNVzeEhw8559IvgbU9AzguQgtyyuDNLNLJ+J5193pn0yCMPq4PIceSJOY0llZqzgzwxk4Mca4Rcr0Qf/NAHae7jm+hz/PH8UqOn2D7Sy/v1vCL/9j1/Q5OFJtVvct2gYYRLZiangkZZg+//x/fRl97yDvoMv7nfNmhZwhYnMDtyvEy3RIRmkRIcuQaSystJPHL8wF7+Zs+0oBJdGODIKSmcukG3zCckIZnxv/JJ49RjDj3lwqfSV+69R/UlZdtSZ6Rx1InJMoKD1KID+w7SR/75n+h6zhye2thJdYTn5IhWjR6gNX97E0m4OFmUHk8beOxwoOipZir+GjU8k175539BD7zn43QLr0/pMBPNwKFXSAmO6nKmrOFCZY7pHgwGe0YXBjjdhE+muomeepX3vNKT7W/QwaQPq+8bJt1acOijr3s1feifPjj1rJxGkl+UujccPdFb3raGTp0YpStj+sjqBWfkiW7gQONL936RfvXfv6RMrtr6TBuIyDrjYFFKbkolOnzwIF35vKvo0a9/XZXISVcdAL3E5TzHyZZS5DfenmCgZ7QhwJFJJkkREiDoYKHbdOt8Q1WrCi69kTftR/IOfeM976I3XPdGKpXK6+TIlg+n1GyPLNuk//3Nf9PDX/kG3WYTndLDpQwvMAw6n4OcN73lzRzcZVRJjuOUyHWlyCq6NmU96BSv4AUdRvFmcfL+FnjkD2vpGU97Nh38+S/obg5upEQOWi9F4x6YBc8s0gWFI3TuISkJrS1nGz3VRFzERBLQHHJyLc/8oDdIC6tPcib5289+hl71mr+giXG/hqoUD9fN8Yv8JHi6jgd7/ZksS4sefZoPk9x/raVb7rqFLA5z5IFnfn9d0cqSfsBSTdbg8fm16NiqP7H/+39/S5dc8Rwa3LJONQM/D5WJW04Cm8SFCjBrUoLz9q0TND8I7GtZNO5sDAZ7RvcFOLokR6eOJ/PYDfMJ9ZAn3X7RM2jtV79Kz7n8ObRt3zbKUYbq7rdugOjmf/og0caH6dmNvwPWkaRE5Tpebf/wvg/QoaMHKJPJUMmWUpygNCbWdGmOGyQh/VaNj49RJmfRD37yPbrkskvoxMNH6F9zBj2p2s8BdDmpZFytdDPqlAO904u4hhIcgAa5oOTRZzNExf/3W3rWZc+ihzY9FPxl9g7u20/vu/kDdB2fp87uowz5mRzMrdh3gN7xjrfT5MQEZTPSAWnt01W4x3EJckqOQ0MjQ3TzJ26m9738/6NPlQr045yp6lAB9LJzx/N0zrHkdw1yzX7mRRt0X4AjD8wLp44n89gN8wmNcGGJ6G6LaNH6HXTxRZfQ//v9/1OfyyPlEu0Frkf5Cf+eeaFQoBvfvoZOO5KnS9Qn/ePcAtG1vC4/++kv0u8ffFBCFf402Y0NKbWRfyX+N+aM0uuvfR3d8/Z30K38m9JSalkRJaqdAGfFRgjvy+Vr9LrdY3RcwodV7ppHPdWLuIZ9rKUa2zkjdKbz+KRyn2XQKUeP0sWXXkxfv+/rvOUNKiZpRm4YNDjodzT12//5LT3y9XtUqdBpfVhX5OWWSefy69ve9jZyHbm4rH26kt6rpGym4BSoUCzQy17xMvr9p79Et/Nnz+6569PuhjCzkWKOjRQVndYtN7bw13vqGTii5QFOuOfUpA8gAug2qwse/cgz6ZZjefrQK15B7/z7dyXvfT9o13ndddfRtbZfKtSP5EGGd2cNGv/N7+gzd97Jn0xniTIUl0FKq6sS2bR792668MILaf+Pfkx38FkOHWZ2Dnk+C24QNl+a3NXweqsXcQ0lOC2DVd1vpKPOv+Jc+F84sPn+zf9Ib3jzNVRSFWWlEqwUJ8QUKQRnpX/+0M1kPPwwPanPC/3OL3l0Pa+Dd3/g7+ngoQPBp5VJCY48XfrSKy+hoQ2P0Z28utFhZgfh/Vta97imSx4nlOM0g8tHgc2rOnkYmXNoezDYUxLnuuquNl9Z1l8Co3dqPxkcOsanyLRUIT+n6OfdkELzzW/kP+gjl/F55lbOZB/68t303D++knYe2s6nHpvy/C/O/u0H6CMf/Bi9meOfC9HShy7NEh2/6yi99/0fIFuVglm8/gx1BvGPJvmMXz2DfvwfD9Czn/NMmv/YdvoqfwHrr7Ooc58hgb1EnZwJh8+TSJzH+ivJME0y+X0t8vVwtxcyLMGNaeQ5Jb8nu2jM2BAM9hQUKzQVr96yitDyPhiEviL1P+T5Lgd+9Eu67MpLaOvhoNNe2R84+eG+v3O88z3vouVje+jKARyeQjq+vMHiIPETn6JH161Va8l2PZqcGKfx/DF+51fM/vzn7qSX/NmLaXWhQN8aMOjMhBUsoXXKT3/YvxtFBTbhlWukK8E5bb/bU72Iay3fw6IRK0C/eDpftH6Gd/mRDVvp6U9+Jm1+bDMVj3Foo/6TNpo2/fI3/0l3fvXTdFOW6PSCX0YBRH9smXT+ENG1b76ebMejAdOi4eG5lBsc4L+a9JGP/hN9/Jq/opsPTtIPiyavu+Qnd2ghyYjdLKccGa70GI8gZzakHpOIPUOYBhX4giBp9jo6YDi91ou4hr2rVXRJDh/gfH6GPiVBzheKRCu2H6bzz38yPXD//VQcd9XVlpys/ubd76Dzef94EYL/MiuKLn2a192xX/+C7v36Par2UsmxaWxsjP7imlfStz74PrqFV5nUeZK6T9DB1LkQWU+9VGVtTvJAP12Co3sQL/Abw78hWNODq7L7+Js914JKdOFepg8OnTqbX2/Jmqq/BHA+Bzjfy5h0VsGmV7zyZXTXXXeRW/LoU5+8lcZ+8St1K2txEVFwlDzh+FqOXd72rjW0f3wfjY6N0ite8mf00F330id5nV6KVdYVXNNWCZWMZ88I9nUJaPzSnOn8UAKcM/MlevKxZAcEf2t/MNhzOj9C6CG6WBHghIJLP82adIsxQbe/9a/ob//2b+md7/o7uon3EXT+WNlT5QnH2w7QG667hi69+lm09xc/pDszflcZ0Plwjdc84aDnxp0TtCDhLW7To57rRVzrwgBHR/06dTZVdBiZz2RxNfS6xSWXrrGJ7uAM+ocfvZmeMJqnZyMDqOopBT558zr63j3fpYHHHqMv8cEkt/2gO+hzn5wXdYYM9eAs3DPL1qVrGakuprMO9Vwv4hpKcJoqHIRNBznIwyBMHkInpRC3WX6LIajujw2TPsuv900a6qnR0EU4IzbJUd3QWnxKlEAHZkeCGr3+HM5UdFDjeQ4ZKZqIL5hwNgeDPacNAY5MMkmK6Jrew32q5Gbq4A3NN++EruyNACFSCoGuBJJZXnTp9SVpZYbcsdtIf6k5DuYztkdZx+RzZMy5HhIzgvVX4osjaTllGvw/r0QjVvL1esoBZ0sw2HMkCEx0lnhgUZbWrFrJOySvQOapJxL5P1CNPKzIcV1at3ajyufPPPv04C+1RIIZHdx0yQExHdyY6p6okAh749ZNdPa4R2/N8BtZFHlwWR3xDkKlDhPdIBWOD7XZefurXhk4qefXSeJh/RNqVP5f8LFSbx0G9ZsJ1DmZimpNPzpdvQ6EWvbgCy4Hg5JRFmsEhbWWI+38RNU7freJLm/V5eM/qm3GI+n9+5/zROt5u5228gQaHBymgumQrSob96vy/GzDho3k8MXO6jNOpWwmQ65acZVJgKieWyyRI+eROf7+aO4QfXnbAbpkX+L1+jLeTPcFwz2l+wKcqC4IeML3Qx9bv9lf45KCg78sRYXGjVXr79Ba0e0Rt02F1BvJcZLdV+/a+lX/hh5XPte7uf5b9DtJpPmuiC5L9H1Urd9PMv3w4Szf10nI36QCtsxHlpM8pVgegxMm300630nmRzRr/G4Tt7zVlk3+Jkn23+A1x6/DAxk6fslyGhgYIJszEPmzvgjsP+X512wDHEcCHB7K8kY6xdlPX9pwkBYmeIr3rnnG+IpRbzWv/p5sJo4Ap4l0YBM+eCcnJ2loaIRGR0dpZHiYDNtV6zBc4hPe6Sss9RS/qWUVtdZPrdt+GD8YqCAyfvRRAF6FM7ftOGTySUnul1tBcbJt+8URZvDec2WnkO/4XZcI/aof4+73aVXFjPmvsTxTguWaWr4a62lK5PdTTn/68fSuOrnL+lGdlPJ8yPrKWHwK5/f5fJ7mzJ1L48cmgu/7PD7o/HVUaf6Tzl+zxo+Kjt/hIss7c/f2/y79Tfm36f3911QZBWfAvG+XSnkamTNElmnxJ/r76qUPla/PdAGOSabr3yGQAMf0XLI4fXTX43T1oWSV036y2vrDFesd6bS/JyHAaRIJbmTHC5fe6HU1FczwMsnJeMY6DC1TePw4/omh3pMnxo+Xfnx53tGU4OQex+EAJ8MnMCGZeLyZ488IoNTfk85/0uXR9O8Gr5WOQW3GsZh++uXL585YvvL1YZZ9XweTU9/h+fX43BOeb5MzVXXMqfFqzZ8er9Jypxw/uv469NxV2cz5LT8/lf9dnlSctXNkOpxnmLyPy/IbNp+zOAfnJNtJBzj9q3z50wY46hyjtoH8jjww1KWP70wV4PyYA5yrgrc9p9/3rqaTc67NSQc7UqAoO6FKvO/GBojqRBD0ths9KYapv1X5u/ob/l5Zk/9ea/sE27myGuOn/nut72v6e0GqOZ+BGd+T4WrvK9HfkwsAj4PG6cTxiSrJ8RNPTv4eBCzqb5ysjOEnS17NsiR/nw6KovMzPd2ypJcr8fJF0ozxApU+71gyr9Xmd/rv+v/+ec9/lVIdqW/jD0verLcDzFb0AlqCxqndO4Gc3Zu9iGtqnQTDVaEEJz2HZ63Iq0sOZmkSmeF1Ja+yznQpTtkVf8T0EldaRv8btUp5ao1fG8aPN3P86czT55cmzBzf5WNCMmBRXipRLjp++e/rEo5aItOvdcxMHWvBa9pMuFZJTpXpq+WrNf1avz+lQ8ePqrI+OlP5/Mr5rQwvz/Q5yS+FlEBU1oN/7vMv8ESlW7j9o3xdpi3B4ZBfDfmtqTh4JIdu2bON/uhAsudNPLrcfOfZe9wPBm97TrcdWV1HBzNxK1o+04HOFNlRgyT3r/0U/MaM5P+qOndUTP5DoOJTtb/p1MnjJ0mtnX+1TUOp2vhq27Zj+/FpsDzJZ6E09d1gXDfJdII047tx06+Rpr4bM/0kvz+VOnD8aKr2t45M5fMr4s5N6m8S2AW3pKRrBj/gqxHsQQrT61PWufAsh88pwZsETt/r9uwzcIScXxPx5J8rlf7KU73kiq1Xk8Wn6xxfygzwVUqOU4aH+fSg/ibFs5LkAkbvpirxSSFN8jdhtVSvsrmLSbXEjRNOcfMsSYsbR6ck4sYLp1rixgmn8vl2eYOGUyWyD8jVmasqElfm34KZTtHpq37OYpPeD8vHT5+C31F7c4I0NV2d4n6zcoou3xTOWFWaIfr9aAp0yvjR1HXKl8+Q0pho4s/lVrwlpZScpOly1vFLr6eCn2D5Z+4v/Z149fF64f94vVUvvZmm1qca4HWfzVNG5Qu1HR3s3V7EtW48wrqG7HhyUOvbUlM7YkB2w2S7IvhXgzEJkolbd5IUeQ2nZotOL5qgu+lgh3exmATNIXWdimaJkj7j76EVAz3bi7iGAAegDeRqDQCgXrq+kxQYnzlWoHOOyO3A2lyTDgSDPQsBDgAAQJeRwEaCGp2kB6Ab9kzSwoRdmBiesTsY7FndE+CE71uHU8PJbzbjdzX9+7OdTr3jt9ss5z9u20tqOZlmOLXaLKcft+4kNZz8ZjN+t1H0/M12Pts9fi3N+l0tPO+zmU6949erx6avbjO7qsWu9Ec1/aDM2rK2uzMY7FkNWMMAAADQDlKSI2U2UoKT5tFCCyedjcFgz0KA0zeiFTl1gtaIW/eSAABmJ3ybSkpxdH2cJE46ZD8eDPYsBDgAAABdSjXJ96T7i6Jqop/UkK26ru1pHRzgyKyFU6NEfzeatEqf1xIdL5pqiRsnnJJqV0lB3DyHU1KzHa9e0elGU1KzXf9x0wynpGY7Xi3R340mrdLnzRadbjTVEjdOONUSN0441SvuN8NJq/R5LdHxoqmWuHHCqdniphlOzRY3zXBqNL9JftYr0lPGjtJTjyaLWXbPM6SX2l/773pXM9Y4AAAAtIR/EXX9rjFaWPQ/qWXdcvNxo8efgSMQ4PSL8MPdwglaI27dSwIAmCX94ESpf5Pu2VrOnmCgp7UkwGlElw4AAAD9Imm+KRWLJcChFPFN1qFtwWBPa3qAU7KDpyryxaqZIbJLJRVpyms2m/X/lojMalxqtGb9biXhZUky3bTfD8Q9A0VSy8k0wymp6Hg6tdospx+37iWlJuOEU1LR8XRqtGb9brOE18Vs5rvd40c16neSCs/7bKZb7/j1avX0y6cneaDkhfL8GsdxOKjhOIX/ZGU4s0xIWlCVeJw0BTgLxo1NwWBPk7XcVLrYLDtoqI1XKBTUe9Oy1CsAAEA/cjmoMUxTdayZn5xUeeTAgEUWf5aULsExUhThnLbf6+lexLWmBzgmbyhJc+bMUT2lTqiN6KnPpMdUAACAfqTzQpfzwvGJCf6AaHhkRBUAJA1ypBChxCMmLcGRXsSHHPpZ8LanNT/A4bUuaWBgQG08XYKT4Q2IujkAANDPJECRvDCfz6s8cnBwcOrORzIueaY0n0pWYPDQisH9/Os934JKSCXsRFHGA4uytGbVSjI9/9aSJzcKWa3u7+W+ogQ3pVKJ1v1hIxkZojPPeoK69yif1StN3xt9rVktdmZVj6SB6l2uVs1/pfnE+utv/b7++3z5pa6NY9tkcz752COb+AOi008/iUbmzKFisXabb4/XXylXotMLB+lr6ydpfqH2+vzp6UN/uHzj5LnB257W9L1DIlG5zyjFbUNzMiQPXDx69KgKbhCcAABAv5L80XFdmhgfV+9H5gxMVTBOfofDpTU7kwU3wvC8nu9FXGt6gCO3oiQ6lQ05f8EC9dmRI0f8e4+oaAwAAH1K6t5ICY5c9EtuPHfuXMpygCP5Y5IAR99B4W/7AwkMON6OYLDnNT3AkSBGNqBEqaoeDsc0kxNFVRdH18cBAADoN3J3w69gPKmah0uBgEhaemO4HjkS3qS4GbJgwumLJuKi6QGOzcFNJptVG2xoaIjmzRtSn0vEKq2okm5IqJPcq45LadU7fru1a/6j09Wp23T7/Hc7rP+eIU3DpQBAWk+5DlEuZ6oKxkLyxloVjU3OOmUPcF2bhqzkz805+aC9JRjseS0/QpYtW8ZBDdGhg6Pq1lXSojgAAIBeIoHMnt0HVN3UVatWqcIAIXc8arakMlwOkhxypBVV8FESQ07v9yKutTzAyeZytHTpAt54RLt37VJFdF1BavvHpW4RN++SukXcvEvqFnHzLqlV4qYtCVojbt1Lgr4kpTdyYb9t2zYOaogWHzdf5Y3SujjNRb+U4pw7WaILjgQ9BtSwZ25/9CKutTzAyWQyNG/ePBoczFB+3KHJ4MF/KMUBAIB+Ic+9GT08qW5LLVywwH82nCulMvLQvuSVam7cM0GLisnyz7XLjb7oRVxrSYAjG0unHEeo8qTGxYsXkzVAtHffPtXeXyogd1SQ0+1XWrOd/+g9/mhqlX6d/0aZ7fTj1lk4QTJY/xBDghe5DSX9T+3YsYOyQ4a64JfPpD5O6jzQtclIc4PK8PqiF3Gt5UeMqnfDUao8yGjZsqVUmvTUhpZKxxLk4Nk4AADQi+QWlOR1GzY8zsEM0aKFC2l4aEg9J06CGwmA0vB7EU8+TsbtnybioqUBjgQvErnKhpTSHIlc5ywYpFLeo507d9LYsWNT3+s8chUWcyU22yu1lpN5i0vdIm7eJXWLCvPbsv1HfjsuQWvErXtJ0E8mJybo8S07aHDQ4jRAQ8PDquRGAhupj5om75O9xzFdHsd/n8TCcWNDMNgXWhrgyEaU2uHyKhGr3HM8fvlymjOfg5xJj7Zt305jwRMdUZLTCpHN33VF4N0+/+2G9ddeWP+9plbdmS2PP07SolueVnzCCSf4JTecJ86m9EaefeNmrVQtqE7b1x+9iGstP6L0DqAiVk5y73HJkiU0f9Ewua5H27ftpGPHjqka5VIhWXaAzgh2ZFU1YnXp32nU7yUVnW4wbTmp6qSE/hZL/73W9xotOt1g2l03//UKz3ua34uOF4zbNeuv20XXW7Du+n39h5e/WuoiksdJ3ibVMSQfkxIauXBf99h6svNEAwM5dWEvt6bk77prBpEmyHElwMlZlOPfT6qfmoiLjthz5AGACxctojkjHOTw6t+2ZTdt375d/c3gAEd2GB3ooGQHAAA6jQQncodCSF0baR0lrYSl+oUksWjZPBXcyIX9pPQeHpK2grGU4JQ4B7cS5om75htye6RvmoiLjgmNdZBz0mkrVeuqg3uP0rp16+jAgQOqOZ0OdCQh0AEAgE4j3S5IF0TjY2M0OjpK27ZupSOHR1W+tWTxIlq6dKlqYCMBkFPnM+CkBKdELpkJK+FsXJLpqybioqPK/iTImcsb//jlx1FmkDfepEe7d+6nPXv3TgU6UuwnkbIOdDpKlxWlQofB/gPQtXR+JPnU/v37adu27TQ5ZtPQ8IB6SvGixYtVKY1KQUlPfVw6b2KSzjmYtE9HY28w0DekgVmicrH7F2VozYqVHBEFnYFJz2DMpMYHGbKjSCAjdXH27t1HTtH/fGTeAM2fP1/1uCrFf9KJpy7WU6U7ZjMzCL1D1phG2kyqZS2v6pz/ls1nJX0+/5UkXq5uX3/dDutfiS5fdLn03ztweXVQEr6VJPmO1KGRUhspsTl86BAVS7bKs7KDBh23bJl6/InkV1IXp1Tyq8BIlQt9OytMfi+pQrZIt2/dTX90MMgga/i/J+W+8Mytxb8M3vaFjgxwNAl0ShzEHDl8mA4fOaJKdBSeheGRnCrtWbBwoepeXu5p6q7nm0IfcDUzoFp/j2rRgVz3/LdoPivp+/mvJOFydfv663ZY/4Ho8kWXS/+9M5dXmnVLkCIBjTzyZGJyUj3XZnKioHoDdwr8nTkZlS/NGRlRQY3JwYw0mBHSFFz3MxVX5yZNgDM5kKc7H99DVx5IVm/40eMy7z57r/2B4G1f6OgAR0jQIjuCbPiJ8XFV9JefcNRcy46Uz9uqF1ZdE12CnrDGleroA67G79U8gUXoE1/T1Tn/LZvPSvp8/itJvFzdvv66Hda/El2+6HLpv3fo8koeJAGOKqXhuGJ4To7yk34JityKks6kpX6NBDVysS0BjjwORV6jzcHrDXDGByfoc1v20hUHkl3UT1r0ymGH7gne9oWOD3ByudzUjiBdOsjOI6+yo0k381KZq1RyVesrmbVoPBOzD82OXsxG/V6ryfx367yLXph/0a5l6Pb11+2w/nuCF6kXnBkg9ZiTBbovKc5wdKmNCFejqBbYaGkCnFOdI3TXpgN0XCHxjvUy/vX7guG+0PEBTi2ys0gpj0TV8ir1dsKq7UxNkfYKPKrZVy4150//PTIfnXJF1e3zX0uz959eX3+drt/Xf5cvv9whkItseX6NqBWQhBvCpHnGTSXSe7iQFlQf3bGNnnc4WQXjPfNocvkonc6j9VUrqq4OcMI7j9zXlGBGouiwRuxU6dQ6gGtp9oFcY/70CWjGCaVTTrDdPv+1NHv/6fX11+n6ff139/KHL5iTlLaE8yhRb34UDnD+mQOc5ycMcH72BHPtZRvcJwZv+0ZXBzjhnU1qpcsOJ62vwjq+BGfGgdxkaedPa/V8VtLt819Ls/efXl9/na7f13+XL7/kMWluIzUqwJGH+klmPdsSnJ+uNn92+Xr3suBt3+j6W1RSk13ueUpLKqloLO/DOr8Ep9UH7ixPMB1zBdnt819Ls/efXl9/na7f139/LX+9AU44oBH6vcP57sd2baXnHUoW4PzyVOtLl2x2XhO87Ruz3ds6hjQPVzXWXVdVPm4/ORCrpXaLm6dw0ip93m7R+YomrdLnnS4639FUr7jfDCet0udQn+h6jSat0ufdLrpc0aRV+ry7ScCTNFkcxGSCpD/L5QZVcONx1p2mF/F5+f7qZFPr+gAHAACgF+kSG82WVlmuR67hcpiTPMI5bb+7KRjsKwhwWq7Tr0w6db6S6vb5r0UvX7OWs1m/C8n0+/rH/hcmgcw0kwOcItleidycS5lo9FPFnBIlu5fVYxDgAAAAdCyXjKBytu065Bgc4GRKZEgN2gR2zrf6rhdxDQEOAABAB5LWU5K0TNaiTM6gJ+bH6IIjybpo2Lgku5V/oq+ef6MhwAEAAOgwEtjo1lNCNXn2PPIyRNdvP0oLi8lKcEzydgeDfacBzcTLH6zXblLTPKz1zcQBAABm5ke1ldc/MoL3pptTwU7JLVBppEif3LiTnn8oWV2l35w88MWLHi+8NnjbV1KV4IQ3lb79h/ABAACgeXRJjnqYrceBTcL6N2Je3unLJuIiVQnOW1asJMvzS2xcw1RFaNJav1zau161otBav4fxq8P41WH86jB+dRi/OoxfXWT8yBObDQ5m5BdcynBObfJ7h05yD9FXNh1MfItqLEuvmluiLwdv+0qttT+N16W0SpMRVOJhi7eFLkKbVmuDAwAAQDV+cMNpKu/lvNX16IY9Y4mDG9GvTcRF4hKcBxZm6a+XryArKLHx40pmcogz4zZj8rjJVykoSvo7GD8exk8G48fD+Mlg/HgYP5nI+EEpjgQ10RIcclz68N7t9LwjefWdWvbOpcnjjvVfL+Ja8gBnUYbesWwlGYa+JaXiSSLLf6qiL2aD1upcLVIkNwPGDwYqwPjBQAUYPxioAOMHAxVg/GCgAowfDFSQevzp9+ouidS3YX6YY5JhE31o787EAc5PzjDXXfGYe1bwtu8kDnD+fVGW3nXKaeTa/tcdfpHAxpMNJK+u/pkaG3SGGjtIzd/D+NVh/OowfnUYvzqMXx3Gry4yfijgkcw5KEbg/5sc8JiUMTL04R076KqDCQOc1ebPrujDXsS1xAHODxdn6f+cdhrlJ/0OLT1Pbk255JryngMcFWlW2JiVotjQxqwK4wcDERg/GKgB4wcDERg/GKgB4wcDERg/GKgh8fgz3+sSHE9uUXH+miWL/mXnXrr6ULKH/P3idPPLz9novip423cSBzg/WpKlG1afRnZQgiPr3TPtUICjfyZmY2IHDwYiMH4wUAPGDwYiMH4wUAPGDwYiMH4wUEPLxp/5fvoWlR/gPHm8RLds2k+r8smm/dAK433n7/LeG7ztO4kDnH0DvLLm5sgy/Y3lkfRlKqMm3MgAAACQiOSvOnOW/Facc9SmZYXkeW4/NxEXiQMcAAAA6Cov49DovmC471QoOwMAAIButauPexHXEOAAAAD0mA193Iu4hgAHAACg9+wNXvsWAhwAAIAek3W9HcFg30KAAwAA0GPm9nEv4hoCHAAAgB5z0gF7UzDYtxDgAAAA9Jh5JUrWn0MPQ4ADAADQQ/bOVcFNXzcRF8YvTs1tDYbj+Q9QjJf0EYGVfqPbx6+lUY9QxPLHw/Ing+WPh+VPBss/O21YfsPwv/zFp4987rP3HHqfegMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADQpYj+f1s3XTNHDcKOAAAAAElFTkSuQmCC';
|
||
icon.style.display = 'block';
|
||
icon.classList.add('spinning');
|
||
|
||
popup.classList.add('active');
|
||
}
|
||
|
||
|
||
|
||
function showConfirm(title, text, onConfirmFunction) {
|
||
document.getElementById('confirm-title').textContent = title;
|
||
document.getElementById('confirm-text').textContent = text;
|
||
|
||
const confirmButton = document.getElementById('confirm-action');
|
||
confirmButton.onclick = () => {
|
||
onConfirmFunction();
|
||
closePopup('confirm-popup');
|
||
};
|
||
|
||
const confirmPopup = document.getElementById('confirm-popup');
|
||
confirmPopup.classList.add('active');
|
||
}
|
||
|
||
function closePopup(popupId) {
|
||
const popup = document.getElementById(popupId);
|
||
popup.classList.remove('active');
|
||
}
|
||
|
||
function closeonlineplayers() {
|
||
const popup = document.getElementById("onlinePlayersPopup");
|
||
popup.style.display = "none";
|
||
}
|
||
|
||
function getTotalDurationSeconds() {
|
||
const days = parseInt(document.getElementById("durDays").value) || 0;
|
||
const hours = parseInt(document.getElementById("durHours").value) || 0;
|
||
const minutes = parseInt(document.getElementById("durMinutes").value) || 0;
|
||
const seconds = parseInt(document.getElementById("durSeconds").value) || 0;
|
||
|
||
return (
|
||
(days * 24 * 60 * 60) +
|
||
(hours * 60 * 60) +
|
||
(minutes * 60) +
|
||
seconds
|
||
);
|
||
}
|
||
|
||
|
||
async function viewOnlinePlayers() {
|
||
try {
|
||
const response = await fetch("/admin/api/who_is_online");
|
||
const playerIds = await response.json();
|
||
|
||
if (playerIds.length === 0) {
|
||
showPopup("Warning!", "There are no players online.");
|
||
return;
|
||
}
|
||
|
||
const queryString = playerIds.map(id => `id=${encodeURIComponent(id)}`).join("&");
|
||
const playerResponse = await fetch(`/admin/api/account/bulk?${queryString}`);
|
||
const playersData = await playerResponse.json();
|
||
|
||
let popupContent = "";
|
||
|
||
const heartbeatPromises = playersData.map(async (playerData) => {
|
||
if (!playerData) return;
|
||
|
||
const heartbeatResponse = await fetch(`/admin/api/heartbeat_data/${playerData.accountId}/name`);
|
||
const heartbeatData = await heartbeatResponse.text();
|
||
|
||
popupContent += `<b>(${playerData.accountId}) ${playerData.username} - is currently in ${heartbeatData}</b><br>`;
|
||
});
|
||
|
||
await Promise.all(heartbeatPromises);
|
||
|
||
showPopup("Online Players", popupContent);
|
||
|
||
} catch (error) {
|
||
console.error(error);
|
||
showPopup("Error!", "Failed to get online players.");
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function LogoutFromAdminPage() {
|
||
GoToPage("/admin/logout");
|
||
}
|
||
|
||
function GoToPage(data_tab) {
|
||
closePopup('confirm-popup');
|
||
showLoading();
|
||
const url = data_tab;
|
||
|
||
new Promise((resolve) => {
|
||
setTimeout(() => {
|
||
resolve();
|
||
}, 200);
|
||
}).then(() => {
|
||
window.location.href = data_tab;
|
||
});
|
||
}
|
||
|
||
const INT32_MAX = 2147483647;
|
||
|
||
const unitMultipliers = {
|
||
seconds: 1,
|
||
minutes: 60,
|
||
hours: 3600,
|
||
days: 86400,
|
||
years: 31536000
|
||
};
|
||
|
||
function updateBanUI() {
|
||
const amountInput = document.getElementById("banAmount");
|
||
const unit = document.getElementById("banUnit").value;
|
||
const preview = document.getElementById("banPreview");
|
||
|
||
if (unit === "permanent") {
|
||
amountInput.value = "";
|
||
amountInput.disabled = true;
|
||
preview.textContent = "This ban is permanent.";
|
||
return;
|
||
}
|
||
|
||
amountInput.disabled = false;
|
||
|
||
const multiplier = unitMultipliers[unit];
|
||
const maxAmount = Math.floor(INT32_MAX / multiplier);
|
||
|
||
amountInput.max = maxAmount;
|
||
|
||
if (parseInt(amountInput.value, 10) > maxAmount) {
|
||
amountInput.value = maxAmount;
|
||
}
|
||
|
||
if (amountInput.value) {
|
||
preview.textContent = `Ban duration: ${amountInput.value} ${unit}`;
|
||
} else {
|
||
preview.textContent = "";
|
||
}
|
||
}
|
||
|
||
function getBanDurationSeconds() {
|
||
const unit = document.getElementById("banUnit").value;
|
||
|
||
if (unit === "permanent") {
|
||
return -1;
|
||
}
|
||
|
||
const amount = parseInt(document.getElementById("banAmount").value, 10);
|
||
if (isNaN(amount) || amount <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
return Math.min(
|
||
amount * unitMultipliers[unit],
|
||
INT32_MAX
|
||
);
|
||
}
|
||
|
||
document.getElementById("banAmount").addEventListener("input", updateBanUI);
|
||
document.getElementById("banUnit").addEventListener("change", updateBanUI);
|
||
|
||
|
||
|
||
|
||
|
||
function sendannoucement() {
|
||
const announcement = document.getElementById('announcementText').value;
|
||
let announcementId = document.getElementById('announcementid').value;
|
||
|
||
if (announcement.trim() === "") {
|
||
showPopup("Error", "Please enter an announcement.");
|
||
return;
|
||
}
|
||
|
||
if (!announcementId.trim()) {
|
||
announcementId = "1";
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append("message", announcement);
|
||
formData.append("playerId", announcementId);
|
||
|
||
fetch("/admin/api/send_annoucement", {
|
||
method: "POST",
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showPopup("Success!", data.message);
|
||
} else {
|
||
showPopup("Failed", data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
showPopup("Error", "An error occurred: " + error.message);
|
||
});
|
||
}
|
||
|
||
function giveTokens(playerid) {
|
||
const tokenAmount = document.getElementById('token-amount').value;
|
||
let playerId = playerid;
|
||
|
||
if (!tokenAmount || tokenAmount <= 0) {
|
||
showPopup("Error", "Please enter a valid amount of tokens.");
|
||
return;
|
||
}
|
||
|
||
if (!playerId.trim()) {
|
||
showPopup("Error", "Please provide a player ID.");
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append("tokenAmount", tokenAmount);
|
||
formData.append("playerId", playerId);
|
||
fetch("/admin/api/give_tokens", {
|
||
method: "POST",
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showPopup("Success!", `Successfully gave ${tokenAmount} tokens to player ${playerId}.`);
|
||
} else {
|
||
showPopup("Failed", data.message);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
showPopup("Error", "An error occurred: " + error.message);
|
||
});
|
||
}
|
||
|
||
|
||
function kick_player(playerId) {
|
||
|
||
const formData = new FormData();
|
||
formData.append("playerId", playerId);
|
||
|
||
fetch("/admin/api/kick_player", {
|
||
method: "POST",
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showPopup("Success!", data.message,);
|
||
closePopup("player-popup")
|
||
} else {
|
||
showPopup("Failed", data.message,);
|
||
closePopup("player-popup")
|
||
}
|
||
})
|
||
.catch(error => {
|
||
showPopup("Error", "An error occurred: " + error.message);
|
||
|
||
});
|
||
}
|
||
|
||
|
||
|
||
function setup_html_show(data_tab) {
|
||
let tabContent = document.getElementById(data_tab);
|
||
|
||
if (!tabContent) {
|
||
tabContent = document.getElementById("home");
|
||
if (!tabContent) return; // still fail-safe if "home" somehow doesnt exist lol
|
||
}
|
||
|
||
document.querySelectorAll('.content').forEach(content => {
|
||
content.classList.remove('active');
|
||
content.style.display = 'none';
|
||
content.style.opacity = 0;
|
||
});
|
||
|
||
tabContent.classList.add('active');
|
||
tabContent.style.display = 'block';
|
||
setTimeout(() => {
|
||
tabContent.style.opacity = 1;
|
||
}, 10);
|
||
if (data_tab === 'Server' && typeof socialAppsLoad === 'function') socialAppsLoad();
|
||
}
|
||
|
||
|
||
// ---- Steam allowlist -------------------------------------------------
|
||
// STEAM ONLY. Quest/Meta logins never consult this list. An empty list turns
|
||
// the check off entirely, which the state line calls out explicitly - an
|
||
// allowlist that silently stopped filtering would be worth noticing.
|
||
|
||
// Steam cell in the player tables: the id plus a one-click allowlist button.
|
||
//
|
||
// Uses player.SteamID, NOT player.PlatformID. PlatformID is the first non-zero
|
||
// id of any platform, so on a Quest account it holds an Oculus id even though the
|
||
// column is labelled "Steam" - adding that to a Steam allowlist would be junk.
|
||
// No Steam id means no button.
|
||
function steamCellHtml(player) {
|
||
const sid = (player.SteamID || '').trim();
|
||
if (!sid) {
|
||
const other = (player.PlatformID || '').trim();
|
||
return other
|
||
? '<span style="opacity:.65">' + other + ' <em>(not Steam)</em></span>'
|
||
: '<span style="opacity:.5">none</span>';
|
||
}
|
||
return 'Steam ' + sid +
|
||
'<br><button class="btn btn-info" style="margin-top:4px; padding:3px 8px; font-size:12px"' +
|
||
' data-steamid="' + sid + '" onclick="steamAllowAddFrom(this)">Add to whitelist</button>';
|
||
}
|
||
|
||
// Discord cell in the player tables: the linked Discord username above its user id.
|
||
// DiscordId/DiscordUsername come from GetAccountBulk_full; both empty = not linked.
|
||
function discordCellHtml(player) {
|
||
const did = (player.DiscordId || '').toString().trim();
|
||
const dname = (player.DiscordUsername || '').trim();
|
||
if (!did && !dname) return '<span style="opacity:.5">not linked</span>';
|
||
return (dname ? escapeHtml(dname) : '<em>unknown</em>') +
|
||
(did ? '<br><span style="opacity:.7; font-size:12px">' + escapeHtml(did) + '</span>' : '');
|
||
}
|
||
|
||
// Adds straight from a player row and reports on the button itself, so the admin
|
||
// gets feedback without a popup interrupting a scan down the table.
|
||
function steamAllowAddFrom(btn) {
|
||
const sid = btn.getAttribute('data-steamid');
|
||
if (!sid) return;
|
||
|
||
const original = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = 'Adding...';
|
||
|
||
const fd = new FormData();
|
||
fd.append('steamId', sid);
|
||
fetch('/admin/api/steam_allowlist/add', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
btn.textContent = data.success ? 'Whitelisted' : (data.message || 'Failed');
|
||
if (data.success) { btn.classList.add('role-on'); }
|
||
else { btn.disabled = false; setTimeout(() => btn.textContent = original, 2500); }
|
||
// Keep the Settings list in step if it is already on screen.
|
||
if (document.getElementById('steamAllowList')) steamAllowLoad();
|
||
})
|
||
.catch(err => {
|
||
btn.disabled = false;
|
||
btn.textContent = original;
|
||
showPopup('Error!', String(err));
|
||
});
|
||
}
|
||
|
||
function steamAllowLoad() {
|
||
fetch('/admin/api/steam_allowlist', { credentials: 'same-origin' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const state = document.getElementById('steamAllowState');
|
||
const list = document.getElementById('steamAllowList');
|
||
if (!state || !list) return;
|
||
|
||
if (data.success === false) {
|
||
state.textContent = data.message || 'Could not load the allowlist.';
|
||
return;
|
||
}
|
||
|
||
state.innerHTML = data.active
|
||
? '<span style="color:#7CFC9B">ACTIVE</span> — ' + data.count +
|
||
' Steam ID(s) may connect. Every other Steam user is refused at login.'
|
||
: '<span style="color:#FFC46B">OFF</span> — the list is empty, so all Steam users can connect.';
|
||
|
||
if (!data.entries || !data.entries.length) {
|
||
list.innerHTML = '<p style="opacity:.6">No IDs on the list.</p>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = data.entries.map(e => {
|
||
const who = e.username
|
||
? '@' + e.username + ' (id ' + e.accountId + ')'
|
||
: '<span style="opacity:.55">no linked Campus account</span>';
|
||
return '<div style="display:flex; align-items:center; gap:10px; padding:8px 4px;' +
|
||
' border-bottom:1px solid rgba(255,255,255,.08)">' +
|
||
'<code style="flex:0 0 auto">' + e.steamId + '</code>' +
|
||
'<span style="flex:1 1 auto">' + who + '</span>' +
|
||
'<button class="btn btn-danger" onclick="steamAllowRemove("' + 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();
|
||
if (tab.getAttribute('data-tab') === 'Database'
|
||
&& typeof loadBackups === 'function') loadBackups();
|
||
if (tab.getAttribute('data-tab') === 'Reports'
|
||
&& typeof reportsLoad === 'function') reportsLoad();
|
||
if (tab.getAttribute('data-tab') === 'Reports' && typeof bugsLoad === 'function') bugsLoad();
|
||
if (tab.getAttribute('data-tab') === 'Economy' && typeof earningsLoad === 'function') earningsLoad();
|
||
if (tab.getAttribute('data-tab') === 'Staff'
|
||
&& typeof staffLoad === 'function') staffLoad();
|
||
if (tab.getAttribute('data-tab') === 'Featured') {
|
||
if (typeof loadFeaturedRooms === 'function') loadFeaturedRooms();
|
||
if (typeof loadFeaturedPlayers === 'function') loadFeaturedPlayers();
|
||
}
|
||
tab.classList.add('active');
|
||
tabContent.classList.add('active');
|
||
tabContent.style.display = 'block';
|
||
setTimeout(() => {
|
||
tabContent.style.opacity = 1;
|
||
}, 10);
|
||
});
|
||
});
|
||
setup_html_show("home");
|
||
// Show the open-report count on the tab straight away, then keep it current.
|
||
if (typeof reportsPoll === 'function') { reportsPoll(); setInterval(reportsPoll, 60000); }
|
||
}
|
||
|
||
// ---- Players table: room-status cell + force join ----
|
||
function escapeHtml(s) {
|
||
return (s == null ? '' : String(s)).replace(/&/g, '&').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" href="/admin/home/view_room/${player.dormroom_id}">View Dorm</a>
|
||
<button class="btn btn-warning btn-edit"
|
||
onclick="viewPlayer('${player.AccountId}', '${attrJs(player.Username)}', '${attrJs(player.Bio)}', '/img/${attrJs(player.ProfileImage)}')">View Player</button>
|
||
<button class="btn btn-role ${player.IsDev ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="dev" data-on="${player.IsDev ? 1 : 0}"
|
||
onclick="toggleRole(this)">Dev</button>
|
||
<button class="btn btn-role ${player.IsMod ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="mod" data-on="${player.IsMod ? 1 : 0}"
|
||
onclick="toggleRole(this)">Mod</button>
|
||
<button class="btn btn-role ${player.IsScreenshare ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="screenshare" data-on="${player.IsScreenshare ? 1 : 0}"
|
||
onclick="toggleRole(this)">Screenshare</button>
|
||
<button class="btn btn-role ${player.IsSupport ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="support" data-on="${player.IsSupport ? 1 : 0}"
|
||
onclick="toggleRole(this)">Support</button>
|
||
<button class="btn btn-role ${player.IsInfluencer ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="influencer" data-on="${player.IsInfluencer ? 1 : 0}"
|
||
onclick="toggleRole(this)">Influencer</button>
|
||
<button class="btn btn-danger btn-delete-player"
|
||
onclick="deletePlayer('${player.AccountId}', '${attrJs(player.Username)}')">Delete</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
document.getElementById("player-list").innerHTML = playerList;
|
||
}
|
||
|
||
function loadPlayers() {
|
||
_playersSearchActive = false; // full list is showing; auto-refresh reloads all
|
||
fetchAsync("/admin/api/players").then(_renderPlayerRows);
|
||
}
|
||
|
||
// Re-run the active loader (search or full) so the list + online/room statuses stay live.
|
||
// Only while the Players tab is visible, so we don't poll in the background.
|
||
function _playersTick() {
|
||
var pane = document.getElementById('Players');
|
||
if (!pane || !pane.classList.contains('active')) return;
|
||
if (_playersSearchActive) { if (typeof searchPlayers === 'function') searchPlayers(); }
|
||
else loadPlayers();
|
||
var s = document.getElementById('players-live');
|
||
if (s) s.textContent = 'live · updated ' + new Date().toLocaleTimeString();
|
||
}
|
||
|
||
async function goto_ban(name, accountid, profileimage, mbd) {
|
||
document.getElementById("banTitle").textContent = `Ban @${name}`;
|
||
document.getElementById("playerid").value = accountid;
|
||
|
||
const avatarImg = document.getElementById("accountimage1");
|
||
avatarImg.src = `/img/${profileimage}?width=128&cropSquare=true`;
|
||
avatarImg.style.display = "block";
|
||
|
||
const banStatusDiv = document.getElementById("banStatus");
|
||
|
||
if (mbd && mbd.IsBan === true) {
|
||
const friendlyDuration = formatDuration(mbd.Duration);
|
||
|
||
banStatusDiv.className = "ban-status bad";
|
||
banStatusDiv.innerHTML = `
|
||
<i class="fas fa-ban"></i>
|
||
<div>
|
||
<strong>@${name} is currently banned</strong><br>
|
||
${friendlyDuration}<br>
|
||
Reason: ${mbd.Message}
|
||
</div>
|
||
`;
|
||
} else {
|
||
banStatusDiv.className = "ban-status ok";
|
||
banStatusDiv.innerHTML = `
|
||
<i class="fas fa-check-circle"></i>
|
||
<strong>@${name} is not currently banned</strong>
|
||
`;
|
||
}
|
||
|
||
banStatusDiv.style.display = "flex";
|
||
|
||
closePopup("player-popup");
|
||
document.getElementById("ban-modal").classList.add("active");
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
const years = Math.floor(seconds / (365 * 24 * 60 * 60));
|
||
seconds %= 365 * 24 * 60 * 60;
|
||
|
||
const days = Math.floor(seconds / (24 * 60 * 60));
|
||
seconds %= 24 * 60 * 60;
|
||
|
||
const hours = Math.floor(seconds / 3600);
|
||
seconds %= 3600;
|
||
|
||
const minutes = Math.floor(seconds / 60);
|
||
|
||
let parts = [];
|
||
|
||
if (years > 0) parts.push(`${years} year${years !== 1 ? "s" : ""}`);
|
||
if (days > 0) parts.push(`${days} day${days !== 1 ? "s" : ""}`);
|
||
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? "s" : ""}`);
|
||
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? "s" : ""}`);
|
||
|
||
if (parts.length === 0) return "less than a minute";
|
||
|
||
return parts.join(", ");
|
||
}
|
||
|
||
|
||
|
||
function searchPlayers() {
|
||
_playersSearchActive = true; // auto-refresh will re-run this search to keep statuses live
|
||
const params = new URLSearchParams();
|
||
|
||
const id = document.getElementById("searchPlayerId").value;
|
||
const displayName = document.getElementById("searchDisplayName").value;
|
||
const email = document.getElementById("searchEmail").value;
|
||
const minLevel = document.getElementById("searchMinLevel").value;
|
||
const jrState = document.getElementById("searchJrState").value;
|
||
const ip = document.getElementById("searchIp").value;
|
||
const deviceId = document.getElementById("searchDeviceId").value;
|
||
const platformId = document.getElementById("searchPlatformId").value;
|
||
const order = document.getElementById("searchOrder").value;
|
||
|
||
if (id) params.append("id", id);
|
||
if (displayName) params.append("displayName", displayName);
|
||
if (email) params.append("email", email);
|
||
if (minLevel) params.append("minLevel", minLevel);
|
||
if (jrState && jrState !== "N/A") params.append("jrState", jrState);
|
||
if (ip) params.append("ip", ip);
|
||
if (deviceId) params.append("deviceId", deviceId);
|
||
if (platformId) params.append("platformId", platformId);
|
||
if (order) params.append("order", order);
|
||
|
||
if (document.getElementById("searchDisplayHistory").checked)
|
||
params.append("searchDisplayHistory", "true");
|
||
|
||
if (document.getElementById("searchPartialMatches").checked)
|
||
params.append("partial", "true");
|
||
|
||
if (document.getElementById("searchJuniorsOnly").checked)
|
||
params.append("juniorsOnly", "true");
|
||
|
||
if (document.getElementById("searchBannedOnly").checked)
|
||
params.append("banned", "true");
|
||
|
||
if (document.getElementById("searchOnlineOnly").checked)
|
||
params.append("online", "true");
|
||
|
||
if (document.getElementById("searchIgnoreNoBirthday").checked)
|
||
params.append("ignoreNoBirthday", "true");
|
||
|
||
fetchAsync(`/admin/api/players?${params.toString()}`).then(function (data) {
|
||
let playerList = '';
|
||
data.forEach(function (player) {
|
||
playerList += `
|
||
<tr>
|
||
<td>${player.AccountId}</td>
|
||
<td>
|
||
<img src="/img/${player.ProfileImage}?width=256&cropSquare=true" class="profile-img"
|
||
alt="${escapeHtml(player.Username)}">
|
||
</td>
|
||
<td>${escapeHtml(player.Username)}</td>
|
||
<td>${steamCellHtml(player)}</td>
|
||
<td style="max-width: 200px; word-break: break-word;">no not yet</td>
|
||
<td>${player.Level}</td>
|
||
<td>${formatDate(player.CreatedAt)}</td>
|
||
<td>${formatDate(player.LastLoginTime)}</td>
|
||
<td>${roomCellHtml(player)}</td>
|
||
<td style="max-width: 200px; word-break: break-word;">${discordCellHtml(player)}</td>
|
||
<td>
|
||
<div class="cell-actions">
|
||
<a class="btn btn-warning btn-room-view" href="/admin/home/view_room/${player.dormroom_id}">View Dorm</a>
|
||
<button class="btn btn-warning btn-edit"
|
||
onclick="viewPlayer('${player.AccountId}', '${attrJs(player.Username)}', '${attrJs(player.Bio)}', '/img/${attrJs(player.ProfileImage)}')">View Player</button>
|
||
<button class="btn btn-role ${player.IsDev ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="dev" data-on="${player.IsDev ? 1 : 0}"
|
||
onclick="toggleRole(this)">Dev</button>
|
||
<button class="btn btn-role ${player.IsMod ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="mod" data-on="${player.IsMod ? 1 : 0}"
|
||
onclick="toggleRole(this)">Mod</button>
|
||
<button class="btn btn-role ${player.IsScreenshare ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="screenshare" data-on="${player.IsScreenshare ? 1 : 0}"
|
||
onclick="toggleRole(this)">Screenshare</button>
|
||
<button class="btn btn-role ${player.IsSupport ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="support" data-on="${player.IsSupport ? 1 : 0}"
|
||
onclick="toggleRole(this)">Support</button>
|
||
<button class="btn btn-role ${player.IsInfluencer ? 'role-on' : ''}"
|
||
data-id="${player.AccountId}" data-role="influencer" data-on="${player.IsInfluencer ? 1 : 0}"
|
||
onclick="toggleRole(this)">Influencer</button>
|
||
<button class="btn btn-danger btn-ban" data-id="${player.AccountId}">Ban</button>
|
||
<button class="btn btn-danger btn-delete-player"
|
||
onclick="deletePlayer('${player.AccountId}', '${attrJs(player.Username)}')">Delete</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
});
|
||
|
||
document.getElementById("player-list").innerHTML = playerList;
|
||
});
|
||
}
|
||
|
||
var _roomsQ = '', _roomsSkip = 0, _roomsTake = 200, _roomsTotal = 0;
|
||
|
||
// Jump pages: dir -1 = previous, +1 = next. Keeps the current search query.
|
||
function roomsPage(dir) {
|
||
var ns = _roomsSkip + dir * _roomsTake;
|
||
if (ns < 0) ns = 0;
|
||
if (ns >= _roomsTotal) return;
|
||
loadRooms(undefined, ns);
|
||
}
|
||
|
||
// q: new search (resets to page 1). skip: jump to an offset. Neither: reload current view.
|
||
function loadRooms(q, skip) {
|
||
if (q !== undefined && q !== null) { _roomsQ = q; _roomsSkip = 0; }
|
||
if (skip !== undefined && skip !== null) _roomsSkip = skip;
|
||
var url = "/admin/api/rooms?take=" + _roomsTake + "&skip=" + _roomsSkip + (_roomsQ ? "&q=" + encodeURIComponent(_roomsQ) : "");
|
||
fetchAsync(url).then(function (data) {
|
||
// Endpoint returns { total, skip, take, rooms:[...] } (paged/projected). Tolerate the
|
||
// old bare-array shape too, and never let one null field blank the whole table.
|
||
var rooms = Array.isArray(data) ? data : (data && data.rooms) || [];
|
||
_roomsTotal = Array.isArray(data) ? data.length : (data && data.total) || rooms.length;
|
||
var roomList = '';
|
||
rooms.forEach(function (room) {
|
||
var name = (room.Name || '').trim();
|
||
var desc = room.Description || '';
|
||
roomList += `
|
||
<tr>
|
||
<td>${room.RoomId}</td>
|
||
<td>
|
||
<img width=100 loading="lazy" src="/img/${room.ImageName || 'DefaultRoomImage.jpg'}?width=256&cropSquare=true" class="room-img" alt="Room Image">
|
||
</td>
|
||
<td>^${escapeHtml(name)}</td>
|
||
<td>${escapeHtml(desc)}</td>
|
||
<td>${room.CreatorAccountId}</td>
|
||
<td>${room.MaxPlayers}</td>
|
||
<td>
|
||
<a href="/admin/edit-room/${room.RoomId}" class="btn btn-warning btn-edit">Edit</a>
|
||
<button class="btn btn-danger btn-delete" data-id="${room.RoomId}">Delete</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
document.getElementById("rooms_list").innerHTML = roomList;
|
||
|
||
// Pager state
|
||
var pageCount = Math.max(1, Math.ceil(_roomsTotal / _roomsTake));
|
||
var curPage = Math.floor(_roomsSkip / _roomsTake) + 1;
|
||
var start = _roomsTotal === 0 ? 0 : _roomsSkip + 1;
|
||
var end = _roomsSkip + rooms.length;
|
||
var cnt = document.getElementById("rooms_count");
|
||
if (cnt) cnt.textContent = "showing " + start + "–" + end + " of " + _roomsTotal
|
||
+ (_roomsQ ? " matching “" + _roomsQ + "”" : "") + " · page " + curPage + " / " + pageCount;
|
||
var prev = document.getElementById("rooms_prev"), next = document.getElementById("rooms_next");
|
||
if (prev) prev.disabled = _roomsSkip <= 0;
|
||
if (next) next.disabled = end >= _roomsTotal;
|
||
// scroll table into view on page change
|
||
window.scrollTo({ top: document.getElementById("rooms_list").getBoundingClientRect().top + window.scrollY - 120, behavior: "smooth" });
|
||
});
|
||
}
|
||
|
||
function loadInventions() {
|
||
fetchAsync("/admin/api/inventions").then(function (data) {
|
||
var inventionList = '';
|
||
data.forEach(function (invention) {
|
||
inventionList += `
|
||
<tr>
|
||
<td>${invention.InventionId}</td>
|
||
<td>
|
||
<img src="/img/${invention.ImageName}?width=256&cropSquare=true" class="profile-img" alt="inventionimg">
|
||
</td>
|
||
<td>${invention.Name}</td>
|
||
<td>${formatDate(invention.CreatedAt)}</td>
|
||
<td>${formatDate(invention.LastModifiedAt)}</td>
|
||
<td>${formatDate(invention.CreatedAt)}</td>
|
||
<td>
|
||
<button class="btn btn-warning btn-edit" onclick="viewInvention('${invention.InventionId}')">View
|
||
Invention</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
document.getElementById("inventions_list").innerHTML = inventionList;
|
||
|
||
});
|
||
|
||
}
|
||
|
||
// ---- Room import drag & drop (uploads become @Importer rooms) ----
|
||
(function initImportDrop() {
|
||
const drop = document.getElementById('importDrop');
|
||
const input = document.getElementById('importFileInput');
|
||
if (!drop || !input) return;
|
||
const status = document.getElementById('importStatus');
|
||
const result = document.getElementById('importResult');
|
||
const lo = () => { drop.style.borderColor = 'var(--line)'; drop.style.background = 'transparent'; };
|
||
const hi = () => { drop.style.borderColor = 'var(--primary)'; drop.style.background = 'var(--primary-soft)'; };
|
||
drop.addEventListener('click', () => input.click());
|
||
drop.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); } });
|
||
['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); hi(); }));
|
||
['dragleave', 'dragend'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); lo(); }));
|
||
drop.addEventListener('drop', e => { e.preventDefault(); lo(); if (e.dataTransfer && e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); });
|
||
input.addEventListener('change', () => { if (input.files.length) uploadFiles(input.files); input.value = ''; });
|
||
|
||
function uploadFiles(files) {
|
||
// XHR (not fetch) so we can show real upload progress and never time out — a room
|
||
// zip can be large, and fetch gives no progress so it looked like it "never finished".
|
||
const fd = new FormData();
|
||
for (const f of files) fd.append('files', f, f.name);
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/admin/api/import_upload', true);
|
||
xhr.withCredentials = true;
|
||
xhr.timeout = 0;
|
||
if (result) result.textContent = '';
|
||
if (status) status.textContent = 'Uploading ' + files.length + ' file(s)…';
|
||
xhr.upload.onprogress = e => {
|
||
if (!status) return;
|
||
if (e.lengthComputable) status.textContent = 'Uploading… ' + Math.round(e.loaded / e.total * 100) + '% (' + fmtBytes(e.loaded) + ' / ' + fmtBytes(e.total) + ')';
|
||
else status.textContent = 'Uploading… ' + fmtBytes(e.loaded);
|
||
};
|
||
xhr.upload.onload = () => { if (status) status.textContent = 'Upload complete — unpacking & importing on the server…'; };
|
||
xhr.onload = () => {
|
||
let d = null; try { d = JSON.parse(xhr.responseText); } catch (e) { }
|
||
if (!d) { if (status) status.textContent = '✗ Server returned ' + xhr.status + ' (not JSON).'; return; }
|
||
if (status) status.textContent = (d.success ? '✓ ' : '✗ ') + (d.message || (d.success ? 'Done.' : 'Import failed.'));
|
||
const lines = [];
|
||
if (d.jsonFiles && d.jsonFiles.length) lines.push('Imported: ' + d.jsonFiles.join(', '));
|
||
if (d.blobFiles && d.blobFiles.length) lines.push('Blobs saved: ' + d.blobFiles.length);
|
||
if (d.blobsMissing && d.blobsMissing.length) lines.push('Still missing (' + d.blobsMissing.length + '): ' + d.blobsMissing.join(', '));
|
||
if (d.errors && d.errors.length) lines.push('Errors: ' + d.errors.join('; '));
|
||
if (result) result.textContent = lines.join('\n');
|
||
};
|
||
xhr.onerror = () => { if (status) status.textContent = '✗ Upload failed (network/size).'; };
|
||
xhr.ontimeout = () => { if (status) status.textContent = '✗ Upload timed out.'; };
|
||
xhr.send(fd);
|
||
}
|
||
})();
|
||
|
||
// ---- Database backups + server restart ----
|
||
function fmtBytes(n) {
|
||
if (!n) return '0 B';
|
||
const u = ['B', 'KB', 'MB', 'GB']; let i = 0;
|
||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||
return n.toFixed(i ? 1 : 0) + ' ' + u[i];
|
||
}
|
||
function loadBackups() {
|
||
const tb = document.getElementById('backup-list'); if (!tb) return;
|
||
tb.innerHTML = '<tr><td colspan="5" style="color:var(--muted);">Loading…</td></tr>';
|
||
fetch('/admin/api/backups', { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
const list = (d && d.backups) || [];
|
||
if (!list.length) { tb.innerHTML = '<tr><td colspan="5" style="color:var(--muted);">No backups yet — one is taken automatically every 8 hours, or press “Back up now”.</td></tr>'; return; }
|
||
tb.innerHTML = list.map(b => {
|
||
let when = b.createdAt; try { when = new Date(b.createdAt).toLocaleString(); } catch (e) { }
|
||
return '<tr><td><code>' + escapeHtml(b.name) + '</code></td><td>' + escapeHtml(when) + '</td><td>' + b.fileCount + '</td><td>' + fmtBytes(b.sizeBytes) + '</td>'
|
||
+ '<td><button class="btn btn-info" onclick="restoreBackup(\'' + escapeHtml(b.name) + '\')"><i class="fa-solid fa-rotate-left"></i> Recover</button></td></tr>';
|
||
}).join('');
|
||
}).catch(() => { tb.innerHTML = '<tr><td colspan="5" style="color:var(--danger);">Failed to load backups.</td></tr>'; });
|
||
}
|
||
function backupNow() {
|
||
const s = document.getElementById('db-status'); if (s) s.textContent = 'Backing up…';
|
||
fetch('/admin/api/backup_now', { method: 'POST', credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||
if (s) s.textContent = (d && d.success) ? ('✓ Backed up: ' + d.name) : 'Backup failed.';
|
||
loadBackups();
|
||
}).catch(() => { if (s) s.textContent = 'Backup failed.'; });
|
||
}
|
||
function restoreBackup(name) {
|
||
const msg = 'This replaces the CURRENT databases with “' + name + '” and restarts the server. Any changes since that snapshot are lost. Continue?';
|
||
const go = () => doRestore(name);
|
||
if (typeof openConfirm === 'function') openConfirm({ title: 'Recover this backup?', message: msg, confirmText: 'Recover & Restart', onConfirm: go });
|
||
else if (confirm(msg)) go();
|
||
}
|
||
function doRestore(name) {
|
||
const form = new URLSearchParams(); form.append('name', name);
|
||
fetch('/admin/api/restore_backup', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString() })
|
||
.then(r => r.json()).then(d => {
|
||
if (typeof showPopup === 'function') showPopup((d && d.success) ? 'Recovering' : 'Error', (d && d.message) || 'Restore failed.');
|
||
else alert((d && d.message) || 'Restore failed.');
|
||
}).catch(() => { alert('Restore request failed.'); });
|
||
}
|
||
function restartServer() {
|
||
const go = () => {
|
||
const s = document.getElementById('server-restart-status'); if (s) s.textContent = 'Restarting…';
|
||
fetch('/admin/api/restart', { method: 'POST', credentials: 'same-origin' }).then(r => r.json())
|
||
.then(d => { if (s) s.textContent = (d && d.message) || 'Restarting…'; })
|
||
.catch(() => { if (s) s.textContent = 'Restart requested.'; });
|
||
};
|
||
const msg = 'Players will be briefly disconnected. The server comes back in a few seconds.';
|
||
if (typeof openConfirm === 'function') openConfirm({ title: 'Restart server?', message: msg, confirmText: 'Restart', onConfirm: go });
|
||
else if (confirm(msg)) go();
|
||
}
|
||
|
||
function toggleRole(btn) {
|
||
const id = btn.dataset.id;
|
||
const role = btn.dataset.role;
|
||
const enable = btn.dataset.on !== "1";
|
||
const form = new URLSearchParams();
|
||
form.append("playerId", id);
|
||
form.append("role", role);
|
||
form.append("enabled", enable ? "1" : "0");
|
||
fetch("/admin/api/set_role", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: form.toString()
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
btn.dataset.on = enable ? "1" : "0";
|
||
btn.classList.toggle("role-on", enable);
|
||
} else {
|
||
showPopup("Error!", data.message || "Role change failed.");
|
||
}
|
||
})
|
||
.catch(err => showPopup("Error!", "Role change failed: " + err));
|
||
}
|
||
|
||
function deletePlayer(accountId, username) {
|
||
if (!confirm(`Permanently delete player @${username} (id ${accountId})?\n\nThis wipes their account, rooms, settings, heartbeat, and image metadata. This cannot be undone.`)) {
|
||
return;
|
||
}
|
||
const form = new URLSearchParams();
|
||
form.append("playerId", accountId);
|
||
fetch("/admin/api/delete_player", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: form.toString()
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showPopup("Success!", `Deleted @${username}.`);
|
||
if (typeof loadPlayers === "function") loadPlayers();
|
||
if (typeof loadPlayersFiltered === "function") loadPlayersFiltered();
|
||
} else {
|
||
showPopup("Error!", data.message || "Delete failed.");
|
||
}
|
||
})
|
||
.catch(err => showPopup("Error!", "Delete request failed: " + err));
|
||
}
|
||
|
||
async function viewPlayer(accountId, me) {
|
||
try {
|
||
showLoading();
|
||
|
||
const bulkData = await fetchAsync(`/admin/api/account/bulk?id=${accountId}`);
|
||
|
||
if (!Array.isArray(bulkData) || bulkData.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const player = bulkData[0];
|
||
const { username, profileImage, displayName, bio, mbd, PlatformIDs } = player;
|
||
|
||
let bioText = "This player has not written a bio yet!";
|
||
if (bio && typeof bio === "string" && bio.trim().length > 0) {
|
||
bioText = bio;
|
||
}
|
||
|
||
const popup = document.getElementById('player-popup');
|
||
document.getElementById('player-popup-title').textContent = "@" + username;
|
||
document.getElementById('player-popup-text').textContent = "Account Id: " + accountId;
|
||
document.getElementById('player-popup-bio').textContent = bioText;
|
||
const platformsDiv = document.getElementById("player-popup-platforms");
|
||
if (platformsDiv) {
|
||
platformsDiv.innerHTML = `
|
||
<h4 class="popup-section-title">Linked Accounts</h4>
|
||
${renderPlatforms(PlatformIDs)}
|
||
`;
|
||
}
|
||
|
||
|
||
try {
|
||
const banStatusDiv = document.getElementById("player-popup-banstatus");
|
||
|
||
if (mbd && mbd.IsBan === true) {
|
||
const friendlyDuration = formatDuration(mbd.Duration);
|
||
|
||
banStatusDiv.className = "ban-status bad";
|
||
banStatusDiv.innerHTML = `
|
||
<i class="fas fa-ban"></i>
|
||
<div>
|
||
<strong>@${username} is currently banned</strong><br>
|
||
${friendlyDuration}<br>
|
||
Reason: ${mbd.Message}
|
||
</div>
|
||
`;
|
||
} else {
|
||
banStatusDiv.className = "ban-status ok";
|
||
banStatusDiv.innerHTML = `
|
||
<i class="fas fa-check-circle"></i>
|
||
<strong>@${name} is not currently banned</strong>
|
||
`;
|
||
}
|
||
|
||
banStatusDiv.style.display = "flex";
|
||
}
|
||
catch (e) {
|
||
console.error("Failed to fetch ban status:", e);
|
||
}
|
||
|
||
const icon = document.getElementById('player-popup-icon');
|
||
|
||
if (profileImage) {
|
||
icon.src = `/img/${profileImage}?width=256&cropSquare=1`;
|
||
icon.style.display = 'block';
|
||
} else {
|
||
icon.style.display = 'none';
|
||
}
|
||
|
||
const buttonContainer = document.getElementById('popup-buttons');
|
||
|
||
if (me === true) {
|
||
document.getElementById('player-popup-text').textContent =
|
||
"This is you!\nAccount Id: " + accountId;
|
||
|
||
buttonContainer.innerHTML = `
|
||
<button class="btn btn-danger" onclick="closePopup('player-popup')">Close</button>
|
||
<button class="btn btn-danger" onclick="GoToCampusNet()">Edit Your Profile</button>
|
||
`;
|
||
} else {
|
||
// IMPORTANT FIX:
|
||
// JSON.stringify + escaping quotes so HTML doesn't break
|
||
const mbdJson = JSON.stringify(mbd).replace(/"/g, '"');
|
||
|
||
buttonContainer.innerHTML = `
|
||
<button class="btn btn-primary" onclick="sendMessage('${accountId}')">Send Message</button>
|
||
<button class="btn btn-danger" onclick="kick_player('${accountId}')">Kick @${username} from their current room</button>
|
||
<button class="btn btn-danger btn-ban" onclick="goto_ban('${username}', '${accountId}', '${profileImage}', ${mbdJson});">
|
||
Ban @${username}
|
||
</button>
|
||
<button class="btn btn-primary" onclick="unban_player('${accountId}', '${username}')">
|
||
Unban @${username}
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="closePopup('player-popup')">Close</button>
|
||
`;
|
||
}
|
||
|
||
closePopup("alert-popup");
|
||
popup.classList.add('active');
|
||
|
||
} catch (error) {
|
||
console.error("Error loading player data:", error);
|
||
showPopup("Error loading player info.");
|
||
}
|
||
}
|
||
|
||
function renderPlatforms(platforms) {
|
||
if (!Array.isArray(platforms)) {
|
||
return `<span class="platform-badge muted">No linked platforms</span>`;
|
||
}
|
||
|
||
const valid = platforms.filter(p =>
|
||
p &&
|
||
p.Platform === 0 && // Steam only
|
||
typeof p.PlatformId === "string" &&
|
||
p.PlatformId !== "0"
|
||
);
|
||
|
||
if (valid.length === 0) {
|
||
return `<span class="platform-badge muted">No linked platforms</span>`;
|
||
}
|
||
|
||
return valid.map(p => `
|
||
<a class="platform-badge steam"
|
||
href="https://steamcommunity.com/profiles/${p.PlatformId}"
|
||
target="_blank">
|
||
<i class="fab fa-steam"></i>
|
||
${p.PlatformId}
|
||
</a>
|
||
`).join("");
|
||
}
|
||
|
||
|
||
|
||
|
||
function GoToCampusNet() {
|
||
closePopup('player-popup')
|
||
// Campus Net was removed; profiles are edited on the website now.
|
||
window.open('/settings', '_blank')
|
||
}
|
||
|
||
function sendMessage(accountId) {
|
||
closePopup('player-popup')
|
||
showPopup('Warning.', 'This is not implemented yet!')
|
||
}
|
||
// <button class="btn btn-danger" onclick="gotohiddenpageEditPlayer()">Edit Player</button>
|
||
// <button class="btn btn-primary" onclick="sendMessage('${accountId}')">Send Message</button>
|
||
|
||
async function viewInvention(inventionId) {
|
||
try {
|
||
const invention = await fetchAsync(`/admin/api/inventions_bulk/${inventionId}`);
|
||
|
||
if (!invention || !invention.CreatorPlayerId) {
|
||
alert("Invalid invention data.");
|
||
return;
|
||
}
|
||
|
||
const {
|
||
Name, Description, ImageName,
|
||
CreatorPlayerId, CreatedAt,
|
||
NumDownloads, CheerCount
|
||
} = invention;
|
||
|
||
const playerArray = await fetchAsync(`/admin/api/account/bulk?id=${CreatorPlayerId}`);
|
||
const playerData = Array.isArray(playerArray) && playerArray.length > 0 ? playerArray[0] : null;
|
||
|
||
const { username, profileImage, displayName } = playerData || {};
|
||
|
||
let bioText = "This player has not written a bio yet!";
|
||
try {
|
||
const bioData = await fetchAsync(`/Accounts/account/${CreatorPlayerId}/bio`);
|
||
if (bioData && typeof bioData.Bio === "string") {
|
||
bioText = bioData.Bio;
|
||
}
|
||
} catch (bioError) {
|
||
|
||
}
|
||
|
||
const popup = document.getElementById('player-popup');
|
||
document.getElementById('player-popup-title').textContent = Name || "Untitled Invention";
|
||
document.getElementById('player-popup-text').textContent =
|
||
`By: ${displayName || username || "Unknown"}<br>` +
|
||
`Account ID: ${CreatorPlayerId}<br>` +
|
||
`Created: ${new Date(CreatedAt).toLocaleString()}<br>` +
|
||
`Downloads: ${NumDownloads} | Cheers: ${CheerCount}`;
|
||
|
||
document.getElementById('player-popup-bio').textContent = Description || bioText;
|
||
|
||
const icon = document.getElementById('player-popup-icon');
|
||
if (ImageName) {
|
||
icon.src = `/img/${ImageName}?cropSquare=1`;
|
||
icon.style.display = 'block';
|
||
} else {
|
||
icon.style.display = 'none';
|
||
}
|
||
|
||
const buttonContainer = document.getElementById('popup-buttons');
|
||
buttonContainer.innerHTML = `
|
||
<button class="btn btn-secondary" onclick="closePopup('player-popup')">Close</button>
|
||
`;
|
||
|
||
|
||
popup.classList.add('active');
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
function gotohiddenpageEditPlayer() {
|
||
closePopup("player-popup");
|
||
setup_html_show("EditPlayer");
|
||
}
|
||
|
||
document.getElementById('theme-selector').addEventListener('change', function () {
|
||
const selectedTheme = this.value;
|
||
|
||
document.body.classList.remove('campus', 'green', 'purple', 'dark', 'red', 'blue', 'pink', 'orange', 'yellow');
|
||
|
||
if (selectedTheme === 'campus_theme') {
|
||
document.body.classList.add('campus');
|
||
const homeImage = document.getElementById('home-image');
|
||
if (homeImage) homeImage.src = `/admin-assets/Cutout.png`;
|
||
} else if (selectedTheme === 'green_theme') {
|
||
document.body.classList.add('green');
|
||
const homeImage = document.getElementById('home-image');
|
||
homeImage.src = `/img/DefaultImgGreen.png`;
|
||
} else if (selectedTheme === 'purple_theme') {
|
||
document.body.classList.add('purple');
|
||
const homeImage = document.getElementById('home-image');
|
||
homeImage.src = `/img/DefaultImgPurple.png`;
|
||
}
|
||
else if (selectedTheme === 'pink_theme') {
|
||
document.body.classList.add('pink');
|
||
const homeImage = document.getElementById('home-image');
|
||
homeImage.src = `/img/DefaultImgPink.png`;
|
||
}
|
||
else if (selectedTheme === 'blue_theme') {
|
||
document.body.classList.add('blue');
|
||
const homeImage = document.getElementById('home-image');
|
||
homeImage.src = `/img/DefaultImg.png`;
|
||
}
|
||
else if (selectedTheme === 'orange_theme') {
|
||
document.body.classList.add('orange');
|
||
const homeImage = document.getElementById('home-image');
|
||
homeImage.src = `/img/DefaultImgOrange.png`;
|
||
}
|
||
else if (selectedTheme === 'yellow_theme') {
|
||
document.body.classList.add('yellow');
|
||
const homeImage = document.getElementById('home-image');
|
||
homeImage.src = `/img/DefaultImgYellow.png`;
|
||
} else if (selectedTheme === 'dark_theme') {
|
||
document.body.classList.add('dark');
|
||
const homeImage = document.getElementById('home-image');
|
||
homeImage.src = `/img/DefaultImgDark.png`;
|
||
} else {
|
||
// Fallback: Campus. Previously fell back to `red` from the E12354 base -
|
||
// now that we ship a Campus theme, default to it so an unknown selection
|
||
// still respects the brand.
|
||
document.body.classList.add('campus');
|
||
const homeImage = document.getElementById('home-image');
|
||
if (homeImage) homeImage.src = `/admin-assets/Cutout.png`;
|
||
}
|
||
|
||
localStorage.setItem('selectedTheme', selectedTheme);
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const savedTheme = localStorage.getItem('selectedTheme');
|
||
if (savedTheme) {
|
||
document.getElementById('theme-selector').value = savedTheme;
|
||
document.getElementById('theme-selector').dispatchEvent(new Event('change'));
|
||
}
|
||
});
|
||
|
||
function initDashboard() {
|
||
// Each step is isolated: a synchronous throw in one used to abort the array before
|
||
// the later ones were even called - which is exactly how updateStats() (and so
|
||
// every dashboard card) could be skipped while one earlier loader failed. Wrapping
|
||
// each call in Promise.resolve().then turns any throw into an isolated rejection,
|
||
// and setup_html runs in its own guard so a failure there can't skip the loaders.
|
||
try { setup_html(); } catch (e) { console.error('setup_html failed:', e); }
|
||
|
||
const step = (name, fn) =>
|
||
Promise.resolve().then(fn).catch(e => console.error(name + ' failed:', e));
|
||
|
||
step('AccountMe', AccountMe);
|
||
step('updateStats', updateStats);
|
||
step('loadPlayers', loadPlayers);
|
||
step('loadRooms', loadRooms);
|
||
step('communityboardMsg', communityboardMsg);
|
||
}
|
||
|
||
initDashboard();
|
||
</script>
|
||
</body>
|
||
|
||
</html> |