yikes,
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<HTML>
|
||||
<HEAD>
|
||||
<TITLE>404 Doodoo Error</TITLE>
|
||||
<script>
|
||||
window.RufflePlayer = window.RufflePlayer || {};
|
||||
window.RufflePlayer.config = {
|
||||
"splashScreen": true
|
||||
};
|
||||
</script>
|
||||
<script src="https://unpkg.com/@ruffle-rs/ruffle"></script>
|
||||
</HEAD>
|
||||
<BODY bgcolor="White" leftmargin=0 topmargin=0>
|
||||
<OBJECT classid="clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"
|
||||
codebase="http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=5,0,0,0"
|
||||
WIDTH=1000 HEIGHT=600>
|
||||
<PARAM NAME=movie VALUE="/vannet/asset/systemisdown.swf"> <PARAM NAME=quality VALUE=high> <PARAM NAME=bgcolor VALUE=#FFFFFF> <EMBED src="/vannet/asset/systemisdown.swf" quality=high bgcolor=#FFFFFF WIDTH=1000 HEIGHT=600 TYPE="application/x-shockwave-flash" PLUGINSPAGE="http://www.macromedia.com/shockwave/download/index.cgi?P1_Prod_Version=ShockwaveFlash"></EMBED>
|
||||
</OBJECT>
|
||||
|
||||
</BODY>
|
||||
</HTML>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,76 @@
|
||||
async function loadAnnouncement() {
|
||||
try {
|
||||
const res = await fetch('https://reloxa.xyz/api/communityboard/v2/current');
|
||||
const data = await res.json();
|
||||
const msg = data?.CurrentAnnouncement?.Message;
|
||||
const url = data?.CurrentAnnouncement?.MoreInfoUrl;
|
||||
if (msg) {
|
||||
const bar = document.getElementById('announcement');
|
||||
bar.style.display = 'block';
|
||||
if (url) {
|
||||
bar.innerHTML = `📢 <a href="${url}" target="_blank" rel="noopener">${msg}</a>`;
|
||||
} else {
|
||||
bar.textContent = '📢 ' + msg;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function loadPhotos() {
|
||||
const grid = document.getElementById('photosGrid');
|
||||
const gridSide = document.getElementById('photosGridSide');
|
||||
try {
|
||||
const res = await fetch('https://reloxa.xyz/api/photos/v1/top/today?skip=0&take=15');
|
||||
const data = await res.json();
|
||||
const photos = Array.isArray(data) ? data : (data.photos || data.Photos || []);
|
||||
if (!photos.length) {
|
||||
grid.innerHTML = '<div style="grid-column:1/-1;padding:8px;color:#666;font-style:italic;font-size:12px">No photos found.</div>';
|
||||
gridSide.innerHTML = '<p class="empty-note">No photos found.</p>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = '';
|
||||
gridSide.innerHTML = '';
|
||||
photos.forEach(photo => {
|
||||
const imgName = photo.ImageName || photo.imageName || '';
|
||||
const url = imgName ? `https://reloxa.xyz/imageserver/${imgName}` : '';
|
||||
const mainItem = document.createElement('div');
|
||||
mainItem.className = 'photo-item';
|
||||
const sideItem = document.createElement('div');
|
||||
sideItem.className = 'photo-item';
|
||||
if (url) {
|
||||
mainItem.innerHTML = `<img src="${url}" alt="photo" loading="lazy"/>`;
|
||||
sideItem.innerHTML = `<img src="${url}" alt="photo" loading="lazy"/>`;
|
||||
} else {
|
||||
mainItem.innerHTML = `<div class="placeholder">No image</div>`;
|
||||
sideItem.innerHTML = `<div class="placeholder">No image</div>`;
|
||||
}
|
||||
grid.appendChild(mainItem);
|
||||
gridSide.appendChild(sideItem);
|
||||
});
|
||||
} catch (e) {
|
||||
grid.innerHTML = '<div style="grid-column:1/-1;padding:8px;color:#666;font-style:italic;font-size:12px">Could not load photos.</div>';
|
||||
gridSide.innerHTML = '<p class="empty-note">Could not load photos.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFeaturedRoom() {
|
||||
try {
|
||||
const res = await fetch('https://reloxa.xyz/api/communityboard/v2/current');
|
||||
const data = await res.json();
|
||||
const rooms = data?.FeaturedRoomGroup?.Rooms;
|
||||
if (rooms && rooms.length) {
|
||||
const room = rooms[0];
|
||||
const card = document.getElementById('featuredRoom');
|
||||
const imgEl = document.getElementById('featuredRoomImg');
|
||||
const nameEl = document.getElementById('featuredRoomName');
|
||||
const imgUrl = `https://reloxa.xyz/imageserver/${room.ImageName}`;
|
||||
imgEl.innerHTML = `<img src="${imgUrl}" alt="${room.RoomName}" onerror="this.style.display='none'"/>`;
|
||||
nameEl.textContent = room.RoomName;
|
||||
card.style.display = 'block';
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
loadAnnouncement();
|
||||
loadPhotos();
|
||||
loadFeaturedRoom();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,147 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; }
|
||||
html { font-family: Arial, sans-serif; font-size: 12px; line-height: 1.5; }
|
||||
body { background: #f0f0f0; min-height: 100vh; display: flex; flex-direction: column; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a.link { color: #5a1fbf; }
|
||||
a.link:hover { text-decoration: underline; }
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
button { cursor: pointer; font: inherit; }
|
||||
|
||||
.topbar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 50;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar-main {
|
||||
background: linear-gradient(to bottom, #6B21A8, #4C0F8F);
|
||||
color: white;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
padding-inline: 5%;
|
||||
}
|
||||
|
||||
.topbar-logo {
|
||||
display: flex; align-items: center; height: 100%;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
.topbar-logo .logo-banner {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
.topbar-logo .logo-small {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-spacer { flex: 1; }
|
||||
|
||||
.topbar-nav {
|
||||
background: #191919; color: white;
|
||||
padding: 3px 0;
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
padding-inline: 5%;
|
||||
}
|
||||
.topbar-nav a:hover { text-decoration: underline; }
|
||||
.topbar-nav .spacer { flex: 1; }
|
||||
|
||||
.announcement-bar {
|
||||
background: #2d1060; color: #e4b4ff;
|
||||
font-size: 11px; text-align: center;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.announcement-bar a { color: #d8a0ff; text-decoration: underline; }
|
||||
|
||||
.page-wrap {
|
||||
margin-top: 62px;
|
||||
display: flex; justify-content: center; flex: 1;
|
||||
}
|
||||
.page-inner {
|
||||
margin: 0 5px 25px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.two-col { display: flex; gap: 10px; margin-top: 10px; }
|
||||
.col-main { flex: 1; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.col-side { width: 280px; flex-shrink: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.card {
|
||||
border: 1px solid #ccc; border-radius: 5px;
|
||||
overflow: hidden; background: white;
|
||||
}
|
||||
.card-header {
|
||||
padding: 8px 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.card-header b { font-size: 16px; }
|
||||
.card-body { padding: 10px; }
|
||||
.card-body p { font-size: 12px; color: #666; font-style: italic; }
|
||||
|
||||
.welcome-card .card-body { padding: 10px; font-size: 12px; }
|
||||
.welcome-card .card-body p.title { font-weight: bold; font-size: 14px; color: #000; font-style: normal; margin-bottom: 4px; }
|
||||
|
||||
.photos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
.photos-grid-side {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
.photo-item {
|
||||
aspect-ratio: 16/9;
|
||||
background: #ddd;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.photo-item img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.photo-item .placeholder {
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #999; font-size: 10px; text-align: center;
|
||||
background: linear-gradient(135deg, #e8d4ff, #d4b0ff);
|
||||
}
|
||||
|
||||
.empty-note { padding: 10px; font-size: 12px; color: #666; font-style: italic; }
|
||||
|
||||
.featured-room-card .room-image {
|
||||
width: 100%; aspect-ratio: 16/9;
|
||||
background: linear-gradient(135deg, #6B21A8, #3b0764);
|
||||
overflow: hidden;
|
||||
}
|
||||
.featured-room-card .room-image img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.featured-room-card .room-label { padding: 6px 10px; font-weight: bold; }
|
||||
|
||||
.section-label {
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
color: #6B21A8; font-weight: bold;
|
||||
letter-spacing: 0.5px; margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.topbar-main, .topbar-nav { padding-inline: 10%; }
|
||||
.announcement-bar { padding-inline: 10%; }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.topbar-main, .topbar-nav { padding-inline: 15%; }
|
||||
.announcement-bar { padding-inline: 15%; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.two-col { flex-direction: column; }
|
||||
.col-side { width: 100%; }
|
||||
.photos-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.logo-banner { display: none !important; }
|
||||
.logo-small { display: block !important; }
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"API": "http://192.168.1.134:8082",
|
||||
"Accounts": "http://192.168.1.134:8082",
|
||||
"Auth": "http://192.168.1.134:8082",
|
||||
"BugReporting": "http://192.168.1.134:8082",
|
||||
"CDN": "http://192.168.1.134:8082",
|
||||
"CMS": "http://192.168.1.134:8082",
|
||||
"Chat": "http://192.168.1.134:8082",
|
||||
"Clubs": "http://192.168.1.134:8082",
|
||||
"Commerce": "http://192.168.1.134:8082",
|
||||
"DataCollection": "http://192.168.1.134:8082",
|
||||
"Discovery": "http://192.168.1.134:8082",
|
||||
"Econ": "http://192.168.1.134:8082",
|
||||
"GameLogs": "http://192.168.1.134:8082",
|
||||
"Images": "http://192.168.1.134:8082",
|
||||
"Leaderboard": "http://192.168.1.134:8082",
|
||||
"Link": "http://192.168.1.134:8082",
|
||||
"Lists": "http://192.168.1.134:8082",
|
||||
"Matchmaking": "http://192.168.1.134:8082",
|
||||
"Moderation": "http://192.168.1.134:8082",
|
||||
"Notifications": "http://192.168.1.134:8082",
|
||||
"PlatformNotifications": "http://192.168.1.134:8082",
|
||||
"PlayerSettings": "http://192.168.1.134:8082",
|
||||
"RoomComments": "http://192.168.1.134:8082",
|
||||
"Rooms": "http://192.168.1.134:8082",
|
||||
"Storage": "http://192.168.1.134:8082",
|
||||
"Strings": "http://192.168.1.134:8082",
|
||||
"StringsCDN": "http://192.168.1.134:8082",
|
||||
"Studio": "http://192.168.1.134:8082",
|
||||
"WWW": "http://192.168.1.134:8082",
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>VanNet</title>
|
||||
<link rel="stylesheet" href="/vannet/asset/style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>will be back soon, temporarily down because we gotta remake it</h1>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,281 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vanadium Director</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='0' y2='1'><stop offset='0%25' stop-color='%237c5cbf'/><stop offset='100%25' stop-color='%235b8dee'/></linearGradient></defs><text y='.9em' font-size='90' fill='url(%23g)'>⬡</text></svg>">
|
||||
<style>
|
||||
@import url('https://fonts.cdnfonts.com/css/futura-pt');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--surface: rgba(255,238,215,0.88);
|
||||
--surface2: rgba(255,228,200,0.68);
|
||||
--border: rgba(0,0,0,0.12);
|
||||
--accent: #f26522;
|
||||
--accent2: #ff9a00;
|
||||
--text: #163573;
|
||||
--text-muted: #5a5a6a;
|
||||
--danger: #c0392b;
|
||||
--success: #27ae60;
|
||||
--discord: #5865f2;
|
||||
--discord-hover: #4752c4;
|
||||
--font: 'Futura PT', Futura, 'Century Gothic', 'Trebuchet MS', sans-serif;
|
||||
--radius: 8px;
|
||||
--radius-sm: 5px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(rgba(0,0,0,0.45), rgba(0,0,0,0.45)), url('https://i.imgur.com/Z0AXeTL.png') center/cover fixed;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
background: linear-gradient(145deg, #ffeedd 0%, #ffeedd 60%, #d8cfc8 100%);
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 16px;
|
||||
padding: 24px 90px;
|
||||
text-align: center;
|
||||
width: 380px;
|
||||
max-width: 92vw;
|
||||
box-shadow: 0 8px 48px rgba(0,0,0,0.35);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 42px;
|
||||
color: #282d4c;
|
||||
-webkit-text-fill-color: #282d4c;
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.auth-box h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #282d4c;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.auth-box .subtitle {
|
||||
color: #5a5a6a;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-box .subtitle strong {
|
||||
color: #282d4c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-discord {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background: linear-gradient(160deg, #cc4a28 0%, #f27530 100%);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 13px 20px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.18s, transform 0.12s, box-shadow 0.18s;
|
||||
letter-spacing: 0.04em;
|
||||
box-shadow: 0 2px 14px rgba(176,62,34,0.35);
|
||||
}
|
||||
|
||||
.btn-discord:hover {
|
||||
opacity: 0.88;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 22px rgba(176,62,34,0.45);
|
||||
}
|
||||
|
||||
.btn-discord:active { transform: translateY(0); }
|
||||
|
||||
.discord-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.divider::before, .divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.requirements {
|
||||
background: rgba(0,0,0,0.18);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.requirements .req-title {
|
||||
color: rgba(255,255,255,0.55);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.requirements ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.requirements ul li {
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.requirements ul li::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#status-msg {
|
||||
margin-top: 18px;
|
||||
min-height: 20px;
|
||||
font-size: 12px;
|
||||
color: #5a5a6a;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#status-msg.err { color: #e74c3c; }
|
||||
#status-msg.ok { color: #4cd98a; }
|
||||
#status-msg.loading { color: var(--accent2); }
|
||||
|
||||
.footer-note {
|
||||
margin-top: 28px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.loading-dot {
|
||||
display: inline-block;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="auth-box">
|
||||
<span class="logo">⬡</span>
|
||||
<h1>Vanadium Director</h1>
|
||||
|
||||
<a id="discord-btn" class="btn-discord" href="https://discord.com/oauth2/authorize?client_id=1512856861374812281&response_type=code&redirect_uri=https%3A%2F%2Freloxa.xyz%2FVanNet%2FDirector&scope=identify+guilds+guilds.members.read">
|
||||
<svg class="discord-icon" viewBox="0 -28.5 256 256" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z"/>
|
||||
</svg>
|
||||
Sign-In Using Discord
|
||||
</a>
|
||||
|
||||
<div id="status-msg"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
const error = params.get("error");
|
||||
const statusEl = document.getElementById("status-msg");
|
||||
const btn = document.getElementById("discord-btn");
|
||||
|
||||
function setStatus(msg, cls) {
|
||||
statusEl.className = cls || "";
|
||||
statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setStatus("Authorization was cancelled or denied.", "err");
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
btn.style.pointerEvents = "none";
|
||||
btn.style.opacity = "0.5";
|
||||
setStatus("Verifying authorization\u2026", "loading");
|
||||
|
||||
fetch("/vannet/api/director/discord-callback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.token) {
|
||||
sessionStorage.setItem("director_token", data.token);
|
||||
sessionStorage.setItem("director_token_expiry", Date.now() + 30 * 60 * 1000);
|
||||
setStatus("Authorized! Redirecting\u2026", "ok");
|
||||
setTimeout(() => {
|
||||
window.location.href = "/vannet/Director/Panel";
|
||||
}, 600);
|
||||
} else {
|
||||
btn.style.pointerEvents = "";
|
||||
btn.style.opacity = "";
|
||||
setStatus(data.error || "You do not have the required role to access this panel.", "err");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.style.pointerEvents = "";
|
||||
btn.style.opacity = "";
|
||||
setStatus("Network error. Please try again.", "err");
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,1603 @@
|
||||
@import url('https://fonts.cdnfonts.com/css/futura-pt');
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: transparent;
|
||||
--bg2: rgba(255,235,210,0.75);
|
||||
--bg3: rgba(255,225,195,0.55);
|
||||
--surface: rgba(255,238,215,0.88);
|
||||
--surface2: rgba(255,228,200,0.68);
|
||||
--border: rgba(0,0,0,0.12);
|
||||
--accent: #f26522;
|
||||
--accent2: #ff9a00;
|
||||
--accent-glow: rgba(242,101,34,0.2);
|
||||
--text: #163573;
|
||||
--text-muted: #5a5a6a;
|
||||
--danger: #c0392b;
|
||||
--danger-hover: #e74c3c;
|
||||
--success: #27ae60;
|
||||
--grad: linear-gradient(180deg, #ffa257 0%, #ffffff 100%);
|
||||
--font: 'Futura PT', Futura, 'Century Gothic', 'Trebuchet MS', sans-serif;
|
||||
--radius: 8px;
|
||||
--radius-sm: 5px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(rgba(0,0,0,0.45), rgba(0,0,0,0.45)), url("https://i.imgur.com/Z0AXeTL.png") center/cover fixed;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
background: #6f768a;
|
||||
border-right: 1px solid rgba(0,0,0,0.18);
|
||||
box-shadow: 2px 0 16px rgba(0,0,0,0.22);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#sidebar::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidebar::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidebar > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 18px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 22px;
|
||||
color: #ffffff;
|
||||
-webkit-text-fill-color: #ffffff;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #ffffff;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.private-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 34px;
|
||||
width: fit-content;
|
||||
min-width: 170px;
|
||||
max-width: 220px;
|
||||
padding: 0 10px;
|
||||
background: #e2dcd0;
|
||||
border: 1.5px solid #e39553;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.private-toggle:hover {
|
||||
background: #ccc7c0;
|
||||
border-color: #e39553;
|
||||
}
|
||||
|
||||
.private-toggle-text {
|
||||
color: #3d3d3d;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.private-toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
margin-left: auto;
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
background: #5a5957;
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
transition: .38s;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
transition: .38s;
|
||||
}
|
||||
|
||||
.private-toggle input:checked + .toggle-slider {
|
||||
background: #ff8c2a;
|
||||
}
|
||||
|
||||
.private-toggle input:checked + .toggle-slider::before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.private-toggle:has(input:focus-visible) {
|
||||
box-shadow: 0 0 0 3px rgba(74,144,255,.35);
|
||||
}
|
||||
|
||||
nav {
|
||||
flex: 1;
|
||||
padding: 10px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section-label {
|
||||
color: rgba(255,255,255,0.5);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 10px 10px 4px;
|
||||
opacity: 1;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.nav-section-label:first-child {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 14px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
filter: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: rgba(0,0,0,0.28);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-btn.active .nav-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
font-size: 11px;
|
||||
border-top: 1px solid rgba(255,255,255,0.15);
|
||||
line-height: 1.6;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
#session-timer-display {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#timer-val {
|
||||
color: var(--accent2);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: none;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
.panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-row input {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="password"],
|
||||
input[type="datetime-local"],
|
||||
select,
|
||||
textarea {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input[type="datetime-local"] {
|
||||
color-scheme: dark;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.6);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select option {
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
padding: 7px 10px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
input[type="file"]:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input[type="file"]:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input[type="file"]::file-selector-button {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-right: 10px;
|
||||
padding: 4px 10px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
input[type="file"]::file-selector-button:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.upload-preview {
|
||||
align-items: center;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.upload-preview img {
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
height: 52px;
|
||||
object-fit: cover;
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
.upload-preview-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-preview-meta strong {
|
||||
color: var(--text);
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
height: 3px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-progress-bar {
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
border-radius: 2px;
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.search-row button,
|
||||
#player-search-btn,
|
||||
#room-search-btn,
|
||||
#img-search-btn {
|
||||
background: linear-gradient(145deg, #ffffff 0%, #ffffff 60%, #dcdde3 100%);
|
||||
color: #282d4c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: linear-gradient(145deg, #ffffff 0%, #ffffff 60%, #dcdde3 100%);
|
||||
color: #282d4c;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.grid-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(242,101,34,0.2);
|
||||
}
|
||||
|
||||
.grid-card-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--bg3);
|
||||
}
|
||||
|
||||
.grid-card-body {
|
||||
padding: 9px 10px 10px;
|
||||
background: #6f768a;
|
||||
}
|
||||
|
||||
.grid-card-name {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.grid-card-sub {
|
||||
color: rgba(255,255,255,0.75);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.grid-card-badge {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
.grid-card-visits {
|
||||
position: absolute;
|
||||
bottom: 44px;
|
||||
right: 7px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 14px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
background: var(--surface2);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.result-card img {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--bg3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-card .rc-name {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.result-card .rc-sub {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-card .rc-right {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-pane {
|
||||
background: #6f768a;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: var(--radius);
|
||||
margin-top: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
background: rgba(0,0,0,0.18);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-header img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--bg3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-header img.room-thumb {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
margin-left: auto;
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
background: rgba(0,0,0,0.12);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.12);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 6px 12px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dtab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
color: rgba(255,255,255,0.55);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 7px 12px 9px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.dtab:hover {
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dtab.active {
|
||||
border-bottom-color: var(--accent);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dtab-content {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dtab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.info-item .info-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.info-item .info-val {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-pane .info-item {
|
||||
background: rgba(0,0,0,0.18);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.detail-pane .info-item .info-label {
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
.detail-pane .info-item .info-val {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.action-section h4 {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.danger-section {
|
||||
border-color: rgba(192,57,43,0.4);
|
||||
}
|
||||
|
||||
.detail-pane .action-section {
|
||||
background: rgba(0,0,0,0.18);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.detail-pane .action-section h4 {
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.detail-pane .field-row label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane .danger-section {
|
||||
border-color: rgba(192,57,43,0.5);
|
||||
}
|
||||
|
||||
.detail-pane .chip {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.detail-pane .muted {
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
.detail-pane .result-card {
|
||||
background: rgba(0,0,0,0.18);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.detail-pane .result-card:hover {
|
||||
background: rgba(0,0,0,0.28);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-pane .result-card .rc-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane .result-card .rc-sub,
|
||||
.detail-pane .result-card .rc-right {
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
.detail-pane .subroom-card {
|
||||
background: rgba(0,0,0,0.18);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane .subroom-card .sr-meta {
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
.detail-pane .subroom-card .sr-refresh-btn:disabled {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: rgba(255,255,255,0.4);
|
||||
}
|
||||
|
||||
.detail-pane .subroom-card .sr-refresh-btn:disabled:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.detail-pane .skin-item {
|
||||
background: rgba(0,0,0,0.18);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane .skin-item:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.detail-pane .btn-role {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane .btn-role:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent2);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.detail-pane .btn-role.has-role {
|
||||
background: rgba(242,101,34,0.3);
|
||||
border-color: var(--accent);
|
||||
color: #f5a060;
|
||||
}
|
||||
|
||||
.detail-pane .role-toggles h4 {
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
.detail-pane .current-tags .tag-chip {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.detail-pane #pd-ban-status,
|
||||
.detail-pane #pa-rrplus-status,
|
||||
.detail-pane #pa-junior-status,
|
||||
.detail-pane #pa-namelock-status,
|
||||
.detail-pane #pa-toxmod-status {
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.detail-pane #mb-badges {
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.detail-pane .empty-state {
|
||||
color: rgba(255,255,255,0.45);
|
||||
}
|
||||
|
||||
.detail-pane input[type="text"],
|
||||
.detail-pane input[type="number"],
|
||||
.detail-pane input[type="password"],
|
||||
.detail-pane input[type="datetime-local"],
|
||||
.detail-pane select,
|
||||
.detail-pane textarea {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane input[type="text"]::placeholder,
|
||||
.detail-pane input[type="number"]::placeholder,
|
||||
.detail-pane textarea::placeholder {
|
||||
color: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
.detail-pane select option {
|
||||
background: #6f768a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane input[type="file"] {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.detail-pane input[type="file"]:hover {
|
||||
background: rgba(0,0,0,0.3);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane .btn-ghost {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
color: rgba(255,255,255,0.75);
|
||||
}
|
||||
|
||||
.detail-pane .btn-ghost:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.detail-pane .role-chip {
|
||||
background: rgba(242,101,34,0.35);
|
||||
border-color: rgba(242,101,34,0.6);
|
||||
color: #ffb880;
|
||||
}
|
||||
|
||||
.detail-pane h4 {
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.detail-pane .badge-online {
|
||||
background: rgba(39,174,96,0.25);
|
||||
color: #4cd98a;
|
||||
}
|
||||
|
||||
.detail-pane .badge-offline {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.detail-pane .badge-banned {
|
||||
background: rgba(192,57,43,0.3);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.detail-pane #pa-rrplus-status,
|
||||
.detail-pane #pa-junior-status,
|
||||
.detail-pane #pa-namelock-status,
|
||||
.detail-pane #pa-toxmod-status,
|
||||
.detail-pane #pa-ban-status {
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
|
||||
.detail-pane #pd-owned-skins h4 {
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.detail-pane .owned-skin-chip {
|
||||
background: rgba(255,154,0,0.25);
|
||||
border-color: rgba(255,154,0,0.45);
|
||||
color: #ffc84a;
|
||||
}
|
||||
|
||||
.detail-pane .owned-skin-chip:hover {
|
||||
border-color: var(--danger-hover);
|
||||
color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.detail-pane .tag-remove-btn {
|
||||
color: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.detail-pane .tag-remove-btn:hover {
|
||||
color: var(--danger-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.detail-pane .upload-preview {
|
||||
background: rgba(0,0,0,0.22);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.detail-pane .upload-preview-meta {
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
|
||||
.detail-pane .upload-preview-meta strong {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field-row label {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.field-row input,
|
||||
.field-row select {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text-muted);
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 9px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-online {
|
||||
background: rgba(39,174,96,0.2);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.badge-offline {
|
||||
background: rgba(127,127,127,0.15);
|
||||
color: #5a5a6a;
|
||||
}
|
||||
|
||||
.badge-banned {
|
||||
background: rgba(192,57,43,0.2);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.8);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.status-dot-online {
|
||||
background: #2ecc71;
|
||||
}
|
||||
|
||||
.status-dot-offline {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.role-chip {
|
||||
background: rgba(242,101,34,0.2);
|
||||
border: 1px solid rgba(242,101,34,0.4);
|
||||
border-radius: 20px;
|
||||
color: #f08040;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 3px 11px;
|
||||
}
|
||||
|
||||
.role-btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-role {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-role:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-role.has-role {
|
||||
background: rgba(242,101,34,0.25);
|
||||
border-color: var(--accent);
|
||||
color: #f5a060;
|
||||
}
|
||||
|
||||
.skin-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 7px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.skin-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.skin-item:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.owned-skin-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.owned-skin-chip {
|
||||
background: rgba(255,154,0,0.15);
|
||||
border: 1px solid rgba(255,154,0,0.3);
|
||||
border-radius: 20px;
|
||||
color: #ffb830;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.owned-skin-chip:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.current-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
padding: 3px 10px 3px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-chip .tag-type-badge {
|
||||
background: rgba(242,101,34,0.2);
|
||||
border: 1px solid rgba(242,101,34,0.35);
|
||||
border-radius: 3px;
|
||||
color: #f08040;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 1px 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tag-chip .tag-type-badge.type-1 {
|
||||
background: rgba(255,154,0,0.2);
|
||||
border-color: rgba(255,154,0,0.35);
|
||||
color: #ffb830;
|
||||
}
|
||||
|
||||
.tag-chip .tag-type-badge.type-2 {
|
||||
background: rgba(39,174,96,0.15);
|
||||
border-color: rgba(39,174,96,0.3);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.tag-chip .tag-type-badge.type-3 {
|
||||
background: rgba(192,57,43,0.2);
|
||||
border-color: rgba(192,57,43,0.35);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.tag-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tag-remove-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--danger-hover);
|
||||
}
|
||||
|
||||
#rd-subrooms-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subroom-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.subroom-card .sr-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subroom-card .sr-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.subroom-card .sr-refresh-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 14px;
|
||||
font-size: 11px;
|
||||
padding: 7px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subroom-card .sr-refresh-btn:disabled {
|
||||
background: var(--surface2);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.subroom-card .sr-refresh-btn:disabled:hover {
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.img-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.img-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.img-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.img-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--bg3);
|
||||
}
|
||||
|
||||
.img-card-meta {
|
||||
padding: 7px 9px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.db-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.db-stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.db-stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.db-stat-value {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: #1a5c30;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.code-block.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent2);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
max-width: 320px;
|
||||
opacity: 0;
|
||||
padding: 11px 16px;
|
||||
pointer-events: none;
|
||||
transform: translateX(20px);
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toast.toast-err {
|
||||
border-left-color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.toast.toast-ok {
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
#modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#modal-box {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.18);
|
||||
padding: 28px 28px 22px;
|
||||
width: 380px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
|
||||
#modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#modal-body {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pd-rooms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-toggles h4 {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#import-drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#import-drop-zone .drop-label {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#import-drop-zone.dragover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#import-room-modal input[type="datetime-local"] {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Anti-Cheat panel ------------------------------------------------------ */
|
||||
|
||||
.ac-note {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.ac-machine {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
/* A banned machine has to be obvious in a list of twenty. */
|
||||
.ac-machine-banned {
|
||||
border-color: rgba(192,57,43,0.45);
|
||||
background: linear-gradient(rgba(192,57,43,0.06), rgba(192,57,43,0.06)), var(--surface);
|
||||
}
|
||||
|
||||
.ac-machine-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ac-hwid {
|
||||
font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ac-machine-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ac-ban-detail {
|
||||
background: rgba(0,0,0,0.05);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.ac-accounts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ac-account {
|
||||
align-items: center;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
gap: 7px;
|
||||
padding: 3px 11px 3px 3px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.ac-account:hover {
|
||||
background: var(--bg3);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ac-account img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--bg3);
|
||||
}
|
||||
|
||||
/* Accounts already banned are dimmed, so the ones a machine ban would newly
|
||||
catch are the ones that stand out. Those are the surprising ones. */
|
||||
.ac-account-banned {
|
||||
border-color: rgba(192,57,43,0.4);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.ac-machine-actions {
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,1264 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vanadium Director</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='0' y2='1'><stop offset='0%25' stop-color='%237c5cbf'/><stop offset='100%25' stop-color='%235b8dee'/></linearGradient></defs><text y='.9em' font-size='90' fill='url(%23g)'>⬡</text></svg>">
|
||||
<link rel="stylesheet" href="/vannet/Director/Asset/Director.css">
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url('https://i.imgur.com/Z0AXeTL.png') center / cover fixed !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app" style="display:none">
|
||||
|
||||
<aside id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-logo">⬡</span>
|
||||
<span class="sidebar-title">Director</span>
|
||||
</div>
|
||||
<nav>
|
||||
<div class="nav-section-label">Management</div>
|
||||
<button class="nav-btn active" data-panel="players">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" /></svg></span> Players
|
||||
</button>
|
||||
<button class="nav-btn" data-panel="rooms">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5L12 3l9 7.5V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z" /><polyline points="9 21 9 12 15 12 15 21" /></svg></span> Rooms
|
||||
</button>
|
||||
<button class="nav-btn" data-panel="images">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></svg></span> Images
|
||||
</button>
|
||||
<div class="nav-section-label">System</div>
|
||||
<button class="nav-btn" data-panel="database">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v4c0 1.66 4.03 3 9 3s9-1.34 9-3V5" /><path d="M3 9v4c0 1.66 4.03 3 9 3s9-1.34 9-3V9" /><path d="M3 13v4c0 1.66 4.03 3 9 3s9-1.34 9-3v-4" /></svg></span> Database
|
||||
</button>
|
||||
<button class="nav-btn" data-panel="moderation">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l7 4v6c0 4.418-3.134 8.548-7 10C8.134 20.548 5 16.418 5 12V6z" /></svg></span> Moderation
|
||||
</button>
|
||||
<button class="nav-btn" data-panel="modboard">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg></span> Mod Board
|
||||
</button>
|
||||
<button class="nav-btn" data-panel="anticheat">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" /><path d="M8 9c0-2.2 1.8-4 4-4s4 1.8 4 4v2" /><path d="M8 13v2" /><path d="M12 11v5" /><path d="M16 13v2" /></svg></span> Anti-Cheat
|
||||
</button>
|
||||
<button class="nav-btn" data-panel="server">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1" /><rect x="2" y="10" width="20" height="5" rx="1" /><rect x="2" y="17" width="20" height="5" rx="1" /><circle cx="6" cy="5.5" r="0.8" fill="white" stroke="none" /><circle cx="6" cy="12.5" r="0.8" fill="white" stroke="none" /><circle cx="6" cy="19.5" r="0.8" fill="white" stroke="none" /></svg></span> Server
|
||||
</button>
|
||||
<button class="nav-btn" data-panel="git">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2" /><circle cx="18" cy="18" r="2" /><circle cx="18" cy="6" r="2" /><path d="M8 6h8" /><path d="M18 8v8" /><path d="M6 8v4a6 6 0 0 0 6 6h4" /></svg></span> Git
|
||||
</button>
|
||||
<div class="nav-section-label">Session</div>
|
||||
<button class="nav-btn" data-panel="session">
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></span> Session
|
||||
</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div id="session-timer-display">Session: <span id="timer-val">30:00</span></div>
|
||||
<div style="margin-top:4px;opacity:0.6">Vanadium Director</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main id="content">
|
||||
|
||||
<div class="panel active" id="panel-players">
|
||||
<div class="panel-header">
|
||||
<h2>Players</h2>
|
||||
<div class="search-row">
|
||||
<input type="text" id="player-search-input" placeholder="Search by username or ID…">
|
||||
<button id="player-search-btn">Search</button>
|
||||
<button id="player-list-all-btn" class="btn-ghost">List All</button>
|
||||
<button id="player-list-devs-btn" class="btn-ghost">List Developers</button>
|
||||
<button id="player-list-mods-btn" class="btn-ghost">List Moderators</button>
|
||||
<button id="player-create-toggle-btn" class="btn-ghost">+ Create Player</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="player-create-section" class="action-section" style="display:none">
|
||||
<h4>Create Player</h4>
|
||||
<div class="field-row">
|
||||
<label>Player ID</label>
|
||||
<input type="number" id="pc-player-id" placeholder="e.g. 1234567" min="1" max="2147483647">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Username</label>
|
||||
<input type="text" id="pc-username" placeholder="optional, random if blank">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label><input type="checkbox" id="pc-junior"> Junior account</label>
|
||||
</div>
|
||||
<button class="btn-action" id="pc-create-btn">Create</button>
|
||||
</div>
|
||||
|
||||
<div id="player-results" class="results-list"></div>
|
||||
|
||||
<div id="player-detail" class="detail-pane" style="display:none">
|
||||
<div class="detail-header">
|
||||
<img id="pd-pfp" src="" alt="pfp">
|
||||
<div>
|
||||
<h3 id="pd-name"></h3>
|
||||
<span id="pd-username" class="muted"></span>
|
||||
<span id="pd-id" class="chip"></span>
|
||||
</div>
|
||||
<button id="pd-close" class="btn-ghost close-btn">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-tabs">
|
||||
<button class="dtab active" data-tab="pd-info">Info</button>
|
||||
<button class="dtab" data-tab="pd-actions">Actions</button>
|
||||
<button class="dtab" data-tab="pd-roles">Roles</button>
|
||||
<button class="dtab" data-tab="pd-skins">Skins</button>
|
||||
<button class="dtab" data-tab="pd-ban">Moderation</button>
|
||||
<button class="dtab" data-tab="pd-tokens">Tokens</button>
|
||||
<button class="dtab" data-tab="pd-rooms">Rooms</button>
|
||||
<button class="dtab" data-tab="pd-beta">BETA</button>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content active" id="pd-info">
|
||||
<div class="info-grid" id="pd-info-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="pd-actions">
|
||||
<div class="action-grid">
|
||||
<div class="action-section" style="grid-column:1/-1">
|
||||
<h4>Identity</h4>
|
||||
<div class="field-row">
|
||||
<label>Username</label>
|
||||
<input type="text" id="pa-username">
|
||||
<button class="btn-action" id="pa-rename-btn">Rename</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div id="pa-namelock-status" style="flex:1"></div>
|
||||
<button class="btn-action" id="pa-clearname-btn">Clear Name</button>
|
||||
<button class="btn-danger" id="pa-namelock-btn">Lock Name</button>
|
||||
<button class="btn-action" id="pa-nameunlock-btn">Unlock Name</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Bio</label>
|
||||
<input type="text" id="pa-bio">
|
||||
<button class="btn-action" id="pa-bio-btn">Set Bio</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Password</label>
|
||||
<input type="text" id="pa-password">
|
||||
<button class="btn-danger" id="pa-password-btn">Set Password</button>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label>Profile Image</label>
|
||||
<input type="text" id="pa-pfp-name" placeholder="filename.png">
|
||||
<button class="btn-action" id="pa-pfp-set-btn">Set PFP</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Upload PFP</label>
|
||||
<input type="file" id="pa-pfp-upload" accept=".jpg,.jpeg,.png" style="flex:1">
|
||||
<button class="btn-action" id="pa-pfp-upload-btn">Upload</button>
|
||||
</div>
|
||||
<button class="btn-danger" id="pa-pfp-reset-btn">Reset PFP to Default</button>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="/*grid-column:1/-1*/">
|
||||
<h4>Level & XP</h4>
|
||||
<div class="field-row">
|
||||
<label>Level</label>
|
||||
<input type="number" id="pa-level" min="1" max="100">
|
||||
<input type="number" id="pa-xp" placeholder="XP (optional)">
|
||||
<button class="btn-action" id="pa-level-btn">Set Level</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="/*grid-column:1/-1*/">
|
||||
<h4>Platform ID</h4>
|
||||
<div class="field-row">
|
||||
<label>Steam ID</label>
|
||||
<input type="text" id="pa-platformid">
|
||||
<button class="btn-action" id="pa-platformid-btn">Override</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="/*grid-column:1/-1*/">
|
||||
<h4>Rec Room Plus</h4>
|
||||
<div id="pa-rrplus-status" style="margin-bottom:6px"></div>
|
||||
<button class="btn-action" id="pa-rrplus-add-btn">Grant RR+</button>
|
||||
<button class="btn-danger" id="pa-rrplus-remove-btn">Remove RR+</button>
|
||||
</div>
|
||||
|
||||
<div class="action-section">
|
||||
<h4>Junior Status</h4>
|
||||
<div id="pa-junior-status" style="margin-bottom:6px"></div>
|
||||
<div class="field-row">
|
||||
<label>Reason</label>
|
||||
<input type="text" id="pa-junior-reason" placeholder="Sent to the player (optional)">
|
||||
</div>
|
||||
<button class="btn-danger" id="pa-junior-set-btn">Set as Junior</button>
|
||||
<button class="btn-action" id="pa-junior-unset-btn">Remove Junior</button>
|
||||
</div>
|
||||
|
||||
<div class="action-section">
|
||||
<h4>Heartbeat</h4>
|
||||
<button class="btn-danger" id="pa-hb-clear-btn">Clear Heartbeat</button>
|
||||
<button class="btn-action" id="pa-hb-view-btn">View Heartbeat</button>
|
||||
</div>
|
||||
|
||||
<div class="action-section">
|
||||
<h4>Force Bring</h4>
|
||||
<div class="field-row">
|
||||
<label>Username</label>
|
||||
<input type="text" id="pa-bring-username" placeholder="Username to bring to">
|
||||
<button class="btn-action" id="pa-bring-btn">Force Bring</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section">
|
||||
<h4>Transfer Rooms</h4>
|
||||
<div class="field-row">
|
||||
<label>New Owner ID</label>
|
||||
<input type="number" id="pa-transfer-target">
|
||||
<button class="btn-action" id="pa-transfer-btn">Transfer All Rooms</button>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top:8px">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="pa-transfer-coowner">
|
||||
Keep original owner as co-owner in all rooms
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section">
|
||||
<h4>Send Websocket</h4>
|
||||
<div class="field-row">
|
||||
<textarea type="text" id="pa-sendws" style="height:10vw;resize:none;"></textarea>
|
||||
<button class="btn-action" id="pa-sendws-btn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section danger-section">
|
||||
<h4>Danger Zone</h4>
|
||||
<button class="btn-danger" id="pa-repair-btn">Repair Account</button>
|
||||
<button class="btn-danger" id="pa-del-images-btn">Delete All Images</button>
|
||||
<button class="btn-danger" id="pa-del-rooms-btn">Delete All Rooms</button>
|
||||
<button class="btn-danger" id="pa-delete-btn">Delete Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="pd-roles">
|
||||
<div class="roles-grid" id="pd-roles-grid"></div>
|
||||
<div class="role-toggles">
|
||||
<h4>Toggle Roles</h4>
|
||||
<div class="role-btn-row">
|
||||
<button class="btn-role" data-role="Developer">Developer</button>
|
||||
<button class="btn-role" data-role="Moderator">Moderator</button>
|
||||
<button class="btn-role" data-role="Screenshare">Screenshare</button>
|
||||
<button class="btn-role" data-role="Keepsake">Keepsake</button>
|
||||
<button class="btn-role" data-role="gameClient">gameClient</button>
|
||||
</div>
|
||||
<h4 style="margin-top:16px">Influencer</h4>
|
||||
<div class="role-btn-row">
|
||||
<button class="btn-action" id="pa-influencer-add">Grant Influencer</button>
|
||||
<button class="btn-danger" id="pa-influencer-remove">Remove Influencer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="pd-skins">
|
||||
<div class="field-row" style="margin-bottom:12px">
|
||||
<input type="text" id="pa-skin-search" placeholder="Search skins…">
|
||||
</div>
|
||||
<div id="pd-skin-list" class="skin-list"></div>
|
||||
<div id="pd-owned-skins">
|
||||
<h4>Owned Skins</h4>
|
||||
<div id="pd-owned-list" class="owned-skin-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="pd-ban">
|
||||
<div class="action-section">
|
||||
<h4>Ban Player</h4>
|
||||
<div class="field-row">
|
||||
<label>Reason</label>
|
||||
<input type="text" id="pa-ban-reason" placeholder="Reason for ban">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Duration (days)</label>
|
||||
<input type="number" id="pa-ban-duration" value="99999">
|
||||
<button class="btn-danger" id="pa-ban-btn">Ban</button>
|
||||
<button class="btn-danger" id="pa-perm-ban-btn">Perm</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Unban Player</h4>
|
||||
<button class="btn-action" id="pa-unban-btn">Unban</button>
|
||||
</div>
|
||||
<div class="action-section" id="pa-ban-status-section">
|
||||
<h4>Current Status</h4>
|
||||
<div id="pa-ban-status"></div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Other Stuff</h4>
|
||||
<button class="btn-danger" id="pa-close-game-btn">Close Game</button>
|
||||
<button class="btn-danger" id="pa-logout-btn">Logout (dose not work)</button>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Coach Message</h4>
|
||||
<div class="field-row">
|
||||
<label>Message</label>
|
||||
<input type="text" id="pa-player-coach-msg" placeholder="Message to send…">
|
||||
<button class="btn-action" id="pa-player-coach-btn">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Edit Raw Moderation Block Details bro</h4>
|
||||
<div class="field-row">
|
||||
<label>Report Category</label>
|
||||
<select id="pa-rmd-category">
|
||||
<option value="-1">Moderator (-1)</option>
|
||||
<option value="0">Unknown (0)</option>
|
||||
<option value="1">DEPRECATED_MicrophoneAbuse (1)</option>
|
||||
<option value="2">Harassment (2)</option>
|
||||
<option value="3">Cheating (3)</option>
|
||||
<option value="4">DEPRECATED_ImmatureBehavior (4)</option>
|
||||
<option value="5">AFK (5)</option>
|
||||
<option value="6">Misc (6)</option>
|
||||
<option value="7">Underage (7)</option>
|
||||
<option value="10">VoteKick (10)</option>
|
||||
<option value="11">MisleadingPurchases (11)</option>
|
||||
<option value="100">CoC_Underage (100)</option>
|
||||
<option value="101">CoC_Sexual (101)</option>
|
||||
<option value="102">CoC_Discrimination (102)</option>
|
||||
<option value="103">CoC_Trolling (103)</option>
|
||||
<option value="104">CoC_NameOrProfile (104)</option>
|
||||
<option value="200">InappropriateClothing (200)</option>
|
||||
<option value="1000">IssuingInaccurateReports (1000)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Duration (days)</label>
|
||||
<input type="number" id="pa-rmd-duration" value="0">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Game Session ID</label>
|
||||
<input type="number" id="pa-rmd-gamesessionid" value="0">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Message</label>
|
||||
<input type="text" id="pa-rmd-message" placeholder="Ban message">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Player ID Reporter</label>
|
||||
<input type="number" id="pa-rmd-reporter" placeholder="optional">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Vote Kick Reason</label>
|
||||
<input type="text" id="pa-rmd-votekickreason" placeholder="optional">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Timeout Started At</label>
|
||||
<input type="datetime-local" id="pa-rmd-timeoutstartedat">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Associated Account Username</label>
|
||||
<input type="text" id="pa-rmd-assocusername" placeholder="optional">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Is Ban</label>
|
||||
<select id="pa-rmd-isban">
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Is Host Kick</label>
|
||||
<select id="pa-rmd-ishostkick">
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Is Voice Mod Autoban</label>
|
||||
<select id="pa-rmd-isvoicemodautoban">
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Is Device Ban</label>
|
||||
<select id="pa-rmd-isdeviceban">
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Is Warning</label>
|
||||
<select id="pa-rmd-iswarning">
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Boot To Dorm</label>
|
||||
<select id="pa-rmd-boottodorm">
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-danger" id="pa-rmd-btn">Apply Raw Mod Details</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="pd-tokens">
|
||||
<div class="action-section">
|
||||
<h4>RecCenterTokens</h4>
|
||||
<div class="field-row">
|
||||
<label>Amount</label>
|
||||
<input type="number" id="pa-token-amount">
|
||||
<label>Type</label>
|
||||
<select id="pa-token-type">
|
||||
<option value="0">NonPurchasedDefault</option>
|
||||
<option value="1">SteamPurchased</option>
|
||||
<option value="2">OculusPurchased</option>
|
||||
<option value="-1">NonPurchasedNotUsableInP2P</option>
|
||||
</select>
|
||||
<button class="btn-action" id="pa-token-btn">Set Tokens</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="pd-rooms">
|
||||
<div id="pd-rooms-list" class="results-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="pd-beta">
|
||||
<div class="action-section">
|
||||
<h4>ToxMod Voice Moderation</h4>
|
||||
<div id="pa-toxmod-status" style="margin-bottom:6px"></div>
|
||||
<div class="field-row">
|
||||
<label>Offense</label>
|
||||
<select id="pa-toxmod-category">
|
||||
<option value="SevereToxicity">Severe Toxicity</option>
|
||||
<option value="HateSpeech">Hate Speech</option>
|
||||
<option value="Harassment">Harassment</option>
|
||||
<option value="SexualContent">Sexual Content</option>
|
||||
<option value="ViolentThreats">Violent Threats</option>
|
||||
<option value="UnderageUser">Underage User</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Duration</label>
|
||||
<select id="pa-toxmod-duration">
|
||||
<option value="">Auto (escalate by strikes)</option>
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="86400">24 hours</option>
|
||||
<option value="259200">3 days</option>
|
||||
<option value="604800">7 days</option>
|
||||
<option value="2592000">30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Note</label>
|
||||
<input type="text" id="pa-toxmod-note" placeholder="Internal note (optional)">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Mode</label>
|
||||
<select id="pa-toxmod-mode">
|
||||
<option value="loud">Loud (quits their game)</option>
|
||||
<option value="quiet">Quiet (silent mute)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-danger" id="pa-toxmod-ban-btn">Issue Voice Ban</button>
|
||||
<button class="btn-action" id="pa-toxmod-unban-btn">Lift Voice Ban</button>
|
||||
<button class="btn-action" id="pa-toxmod-reset-btn">Reset Strikes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-rooms">
|
||||
<div class="panel-header">
|
||||
<h2>Rooms</h2>
|
||||
<div class="search-row">
|
||||
<input type="text" id="room-search-input" placeholder="Search by name or ID…">
|
||||
<button id="room-search-btn">Search</button>
|
||||
<button id="room-hot-btn" class="btn-ghost">Hot Rooms</button>
|
||||
<button id="room-import-btn" class="btn-ghost">Meow.Net Room Importer</button>
|
||||
<button id="room-import-epicquest-btn" class="btn-ghost">EpicQuest Room Importer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="room-results" class="results-list"></div>
|
||||
|
||||
<div id="room-detail" class="detail-pane" style="display:none">
|
||||
<div class="detail-header">
|
||||
<img id="rd-img" src="" alt="room" class="room-thumb">
|
||||
<div>
|
||||
<h3 id="rd-name"></h3>
|
||||
<span id="rd-id" class="chip"></span>
|
||||
<span id="rd-creator" class="muted"></span>
|
||||
</div>
|
||||
<button id="rd-close" class="btn-ghost close-btn">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-tabs">
|
||||
<button class="dtab active" data-tab="rd-info">Info</button>
|
||||
<button class="dtab" data-tab="rd-actions">Actions</button>
|
||||
<button class="dtab" data-tab="rd-display">Display</button>
|
||||
<button class="dtab" data-tab="rd-access">Access</button>
|
||||
<button class="dtab" data-tab="rd-tags">Tags</button>
|
||||
<button class="dtab" data-tab="rd-subrooms">SubRooms</button>
|
||||
<button class="dtab" data-tab="rd-danger">Danger</button>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content active" id="rd-info">
|
||||
<div class="info-grid" id="rd-info-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="rd-actions">
|
||||
<div class="action-grid">
|
||||
<div class="action-section">
|
||||
<h4>Identity</h4>
|
||||
<div class="field-row">
|
||||
<label>Name</label>
|
||||
<input type="text" id="ra-name">
|
||||
<button class="btn-action" id="ra-name-btn">Set Name</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Description</label>
|
||||
<input type="text" id="ra-desc">
|
||||
<button class="btn-action" id="ra-desc-btn">Set Description</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="grid-column:1/-1">
|
||||
<h4>Ownership</h4>
|
||||
<div class="field-row">
|
||||
<label>New Owner ID</label>
|
||||
<input type="number" id="ra-owner">
|
||||
<button class="btn-action" id="ra-owner-btn">Transfer Ownership</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="grid-column:1/-1">
|
||||
<h4>Roles</h4>
|
||||
<div class="field-row">
|
||||
<label>Player ID</label>
|
||||
<input type="number" id="ra-role-player">
|
||||
<label>Role</label>
|
||||
<select id="ra-role-val">
|
||||
<option value="0">None</option>
|
||||
<option value="1">Banned</option>
|
||||
<option value="10">Host</option>
|
||||
<option value="20">Moderator</option>
|
||||
<option value="30">Co-Owner</option>
|
||||
<option value="31">Contributer</option>
|
||||
</select>
|
||||
<button class="btn-action" id="ra-role-btn">Set Role</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="grid-column:1/-1">
|
||||
<h4>Beta Tag</h4>
|
||||
<button class="btn-action" id="ra-beta-btn">Toggle Beta Tag</button>
|
||||
</div>
|
||||
<div class="action-section" style="grid-column:1/-1">
|
||||
<h4>DataBlob Import</h4>
|
||||
<div class="field-row" style="margin-bottom:8px">
|
||||
<label>SubRoom</label>
|
||||
<select id="ra-blob-subroom-select" style="flex:1">
|
||||
<option value="">Select SubRoom…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>DataBlob</label>
|
||||
<input type="text" id="ra-blob-name" placeholder="blobfilename">
|
||||
<button class="btn-action" id="ra-blob-btn">Import DataBlob</button>
|
||||
<label for="ra-blob-upload" class="btn-action" style="padding:8px 10px;cursor:pointer;background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;font-weight:600;border-radius:var(--radius-sm)">Upload DataBlob</label>
|
||||
<input type="file" id="ra-blob-upload" accept=".room" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="rd-display">
|
||||
<div class="action-grid">
|
||||
<div class="action-section">
|
||||
<h4>Room Image</h4>
|
||||
<div class="field-row">
|
||||
<label>Image File</label>
|
||||
<input type="text" id="ra-image" placeholder="filename.jpg">
|
||||
<button class="btn-action" id="ra-image-btn">Set Image</button>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Upload Image</label>
|
||||
<input type="file" id="ra-image-upload" accept=".jpg,.jpeg,.png" style="flex:1">
|
||||
<button class="btn-action" id="ra-image-upload-btn">Upload</button>
|
||||
</div>
|
||||
<button class="btn-danger" id="ra-image-reset-btn">Reset to Default</button>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Stats (Read Only)</h4>
|
||||
<div class="info-grid" id="rd-stats-grid" style="margin-top:0"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="rd-access">
|
||||
<div class="action-grid">
|
||||
<div class="action-section">
|
||||
<h4>Accessibility</h4>
|
||||
<div class="field-row">
|
||||
<label>Mode</label>
|
||||
<select id="ra-access">
|
||||
<option value="0">Private</option>
|
||||
<option value="1">Public</option>
|
||||
<option value="2">Unlisted</option>
|
||||
<option value="3">Dev Only</option>
|
||||
<option value="4">Dev Unlisted</option>
|
||||
</select>
|
||||
<button class="btn-action" id="ra-access-btn">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Cloning</h4>
|
||||
<div class="field-row">
|
||||
<label>Allow Cloning</label>
|
||||
<select id="ra-cloning">
|
||||
<option value="true">Allowed</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
<button class="btn-action" id="ra-cloning-btn">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Platform Support</h4>
|
||||
<div class="field-row">
|
||||
<label>Mobile</label>
|
||||
<select id="ra-mobile">
|
||||
<option value="true">Supported</option>
|
||||
<option value="false">Not Supported</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Screens</label>
|
||||
<select id="ra-screens">
|
||||
<option value="true">Supported</option>
|
||||
<option value="false">Not Supported</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Walk VR</label>
|
||||
<select id="ra-walkvr">
|
||||
<option value="true">Supported</option>
|
||||
<option value="false">Not Supported</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Teleport VR</label>
|
||||
<select id="ra-teleportvr">
|
||||
<option value="true">Supported</option>
|
||||
<option value="false">Not Supported</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Juniors</label>
|
||||
<select id="ra-juniors">
|
||||
<option value="true">Allowed</option>
|
||||
<option value="false">Not Allowed</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-action" id="ra-restrictions-btn">Save Platform Settings</button>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Age Rating</h4>
|
||||
<div class="field-row">
|
||||
<label>Rating</label>
|
||||
<select id="ra-agerating">
|
||||
<option value="0">All Ages</option>
|
||||
<option value="1">Teen (13+)</option>
|
||||
<option value="2">Mature (17+)</option>
|
||||
</select>
|
||||
<button class="btn-action" id="ra-agerating-btn">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Max Players</h4>
|
||||
<div class="field-row">
|
||||
<label>Max</label>
|
||||
<input type="number" id="ra-maxplayers" min="1" max="64">
|
||||
<button class="btn-action" id="ra-maxplayers-btn">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="rd-tags">
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:10px">Current Tags</div>
|
||||
<div class="current-tags" id="rd-current-tags"></div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Add / Remove Tag</h4>
|
||||
<div class="field-row">
|
||||
<label>Tag</label>
|
||||
<input type="text" id="ra-tag-input" placeholder="tag name">
|
||||
<label>Type</label>
|
||||
<select id="ra-tag-type">
|
||||
<option value="0">General (0)</option>
|
||||
<option value="1">Auto (1)</option>
|
||||
<option value="2">AG Only (2)</option>
|
||||
<option value="3">Banned (3)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row" style="margin-top:6px">
|
||||
<button class="btn-action" id="ra-tag-add-btn">Add Tag</button>
|
||||
<button class="btn-danger" id="ra-tag-remove-btn">Remove Tag</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="rd-subrooms">
|
||||
<div id="rd-subrooms-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="dtab-content" id="rd-danger">
|
||||
<div class="action-section danger-section">
|
||||
<h4>Danger Zone</h4>
|
||||
<div class="field-row">
|
||||
<label>Room Type</label>
|
||||
<select id="ra-roomtype">
|
||||
<option value="false">Rooms 1</option>
|
||||
<option value="true">Rooms 2</option>
|
||||
</select>
|
||||
<button class="btn-action" id="ra-roomtype-btn">Set</button>
|
||||
</div>
|
||||
<button class="btn-danger" id="ra-set-modern-persistence-btn">Set Modern Persistence Values</button>
|
||||
<button class="btn-danger" id="ra-delete-btn">Delete Room Permanently</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-images">
|
||||
<div class="panel-header">
|
||||
<h2>Images</h2>
|
||||
<div class="search-row">
|
||||
<input type="number" id="img-player-id" placeholder="Player ID">
|
||||
<button id="img-search-btn">Load Player Images</button>
|
||||
<button id="img-global-btn" class="btn-ghost">Global Feed</button>
|
||||
<label class="private-toggle">
|
||||
<span class="private-toggle-text">Show Private Images</span>
|
||||
|
||||
<input type="checkbox" id="img-show-private">
|
||||
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="img-results" class="img-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="img-detail-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:5000;align-items:center;justify-content:center">
|
||||
<div id="img-detail-drawer" style="background:var(--surface);border-radius:var(--radius);border:1px solid var(--accent);width:90%;max-width:900px;max-height:88vh;overflow-y:auto;padding:0;display:flex;flex-direction:column">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--surface);z-index:1">
|
||||
<div style="font-size:15px;font-weight:700">Image Actions</div>
|
||||
<button id="img-detail-close" class="btn-ghost close-btn">✕</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:0;flex:1;min-height:0">
|
||||
<div style="width:220px;flex-shrink:0;padding:20px;border-right:1px solid var(--border);display:flex;flex-direction:column;gap:10px">
|
||||
<img id="imgd-preview" src="" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:var(--radius-sm);background:var(--bg3)" onerror="this.src='/imageserver/DefaultPFP.png'">
|
||||
<div id="imgd-type-badge" style="text-align:center;font-size:11px;padding:4px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm)"></div>
|
||||
<div id="imgd-access-badge" style="text-align:center;font-size:11px;padding:4px 8px;border-radius:var(--radius-sm)"></div>
|
||||
</div>
|
||||
<div style="flex:1;padding:20px;display:flex;flex-direction:column;gap:18px;overflow-y:auto">
|
||||
<div class="action-section" style="margin:0">
|
||||
<div class="info-grid" id="imgd-info-grid" style="margin-top:0"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Accessibility</h4>
|
||||
<div class="field-row">
|
||||
<label>Mode</label>
|
||||
<select id="imgd-access-select">
|
||||
<option value="0">Private</option>
|
||||
<option value="1">Public</option>
|
||||
</select>
|
||||
<button class="btn-action" id="imgd-access-btn">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Accessibility Lock</h4>
|
||||
<div id="imgd-lock-status" style="margin-bottom:8px;font-size:12px"></div>
|
||||
<div class="field-row">
|
||||
<button class="btn-danger" id="imgd-lock-btn">Lock Accessibility</button>
|
||||
<button class="btn-action" id="imgd-unlock-btn">Unlock Accessibility</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Description</h4>
|
||||
<div class="field-row">
|
||||
<input type="text" id="imgd-desc-input" placeholder="Image description…">
|
||||
<button class="btn-action" id="imgd-desc-btn">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Tagged Players</h4>
|
||||
<div id="imgd-tagged-players" style="font-size:12px;color:var(--text-muted);display:flex;flex-wrap:wrap;gap:4px"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Room</h4>
|
||||
<div id="imgd-room-info" style="font-size:12px"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Cheers</h4>
|
||||
<div id="imgd-cheer-info" style="margin-bottom:8px;font-size:12px;color:var(--text-muted)"></div>
|
||||
<button class="btn-danger" id="imgd-reset-cheers-btn">Reset Cheers to 0</button>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Dev Lock</h4>
|
||||
<div id="imgd-devlock-status" style="margin-bottom:8px;font-size:12px"></div>
|
||||
<div class="field-row">
|
||||
<button class="btn-danger" id="imgd-devlock-btn">Enable Dev Lock</button>
|
||||
<button class="btn-action" id="imgd-devunlock-btn">Remove Dev Lock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Transfer Ownership</h4>
|
||||
<div class="field-row">
|
||||
<label>New Player ID</label>
|
||||
<input type="number" id="imgd-transfer-id" placeholder="Player ID">
|
||||
<button class="btn-action" id="imgd-transfer-btn">Transfer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>File Info</h4>
|
||||
<div id="imgd-filename-display" style="font-size:12px;color:var(--text-muted);margin-bottom:8px;word-break:break-all"></div>
|
||||
<button class="btn-ghost" id="imgd-copy-name-btn">Copy File Name</button>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin:0">
|
||||
<h4>Full Metadata</h4>
|
||||
<button class="btn-ghost" id="imgd-json-btn">View Raw JSON</button>
|
||||
<pre id="imgd-json-block" style="display:none;margin-top:10px;background:rgba(0,0,0,0.08);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-size:11px;white-space:pre-wrap;word-break:break-all;max-height:240px;overflow-y:auto;color:var(--text)"></pre>
|
||||
</div>
|
||||
|
||||
<div class="action-section danger-section" style="margin:0">
|
||||
<h4>Danger Zone</h4>
|
||||
<button class="btn-danger" id="imgd-delete-btn">Delete Image Permanently</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-database">
|
||||
<div class="panel-header">
|
||||
<h2>Database</h2>
|
||||
</div>
|
||||
<div class="db-grid">
|
||||
<div class="db-stat-card">
|
||||
<span class="db-stat-label">Players</span>
|
||||
<span class="db-stat-value" id="dbs-players">-</span>
|
||||
</div>
|
||||
<div class="db-stat-card">
|
||||
<span class="db-stat-label">Rooms</span>
|
||||
<span class="db-stat-value" id="dbs-rooms">-</span>
|
||||
</div>
|
||||
<div class="db-stat-card">
|
||||
<span class="db-stat-label">SubRoom Saves</span>
|
||||
<span class="db-stat-value" id="dbs-saves">-</span>
|
||||
</div>
|
||||
<div class="db-stat-card">
|
||||
<span class="db-stat-label">Inventions</span>
|
||||
<span class="db-stat-value" id="dbs-inventions">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="margin-top:24px">
|
||||
<h4>Execute Expression</h4>
|
||||
<div class="field-row">
|
||||
<input type="text" id="db-expr-input" placeholder="e.g. RoomDB.Rooms.Count()">
|
||||
<button class="btn-action" id="db-exec-btn">Execute</button>
|
||||
</div>
|
||||
<pre id="db-exec-result" class="code-block"></pre>
|
||||
</div>
|
||||
<div class="action-section" style="margin-top:24px">
|
||||
<h4>Gift Every Player Tokens (doesnt send gift and is bugged)</h4>
|
||||
<div class="field-row">
|
||||
<label>Amount</label>
|
||||
<input type="number" id="db-gift-amount" min="1" placeholder="e.g. 100000">
|
||||
<label>Message</label>
|
||||
<input type="text" id="db-gift-msg" placeholder="A gift for you <3 (optional)">
|
||||
</div>
|
||||
<button class="btn-action" id="db-gift-tokens-btn" style="margin-top:8px">Gift All Players</button>
|
||||
</div>
|
||||
<button id="db-refresh-btn" class="btn-action" style="margin-top:16px">Refresh Stats</button>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-moderation">
|
||||
<div class="panel-header">
|
||||
<h2>Moderation</h2>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Crash Everyone (Developers Excluded)</h4>
|
||||
<button class="btn-danger" id="crash-all-btn">Close everyones game that isnt a dev</button>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Quick Ban by ID</h4>
|
||||
<div class="field-row">
|
||||
<label>Player ID</label>
|
||||
<input type="number" id="mod-ban-id">
|
||||
<label>Reason</label>
|
||||
<input type="text" id="mod-ban-reason" placeholder="Reason">
|
||||
<label>Days</label>
|
||||
<input type="number" id="mod-ban-days" value="99999">
|
||||
<button class="btn-danger" id="mod-ban-btn">Ban</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Quick Unban by ID</h4>
|
||||
<div class="field-row">
|
||||
<label>Player ID</label>
|
||||
<input type="number" id="mod-unban-id">
|
||||
<button class="btn-action" id="mod-unban-btn">Unban</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Shove Player</h4>
|
||||
<div class="field-row">
|
||||
<label>Player ID</label>
|
||||
<input type="number" id="mod-shove-player">
|
||||
<label>Room ID</label>
|
||||
<input type="number" id="mod-shove-room">
|
||||
<label>SubRoom</label>
|
||||
<input type="number" id="mod-shove-subroom" placeholder="opt">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Instance ID</label>
|
||||
<input type="text" id="mod-shove-instance" placeholder="opt">
|
||||
<button class="btn-action" id="mod-shove-btn">Shove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Clear Heartbeat</h4>
|
||||
<div class="field-row">
|
||||
<label>Player ID</label>
|
||||
<input type="number" id="mod-hb-id">
|
||||
<button class="btn-danger" id="mod-hb-btn">Clear Heartbeat</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Drag All Online to Room</h4>
|
||||
<div class="field-row">
|
||||
<label>Room ID</label>
|
||||
<input type="number" id="mod-dragall-room">
|
||||
<label>SubRoom</label>
|
||||
<input type="number" id="mod-dragall-subroom" placeholder="opt">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Instance ID</label>
|
||||
<input type="text" id="mod-dragall-instance" placeholder="opt">
|
||||
<button class="btn-danger" id="mod-dragall-btn">Drag All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-modboard">
|
||||
<div class="panel-header">
|
||||
<h2>Moderator Board</h2>
|
||||
<div class="search-row">
|
||||
<input type="text" id="mb-lookup-input" placeholder="Player ID or username…">
|
||||
<button id="mb-lookup-btn">Lookup</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mb-player-card" class="detail-pane" style="display:none">
|
||||
<div class="detail-header">
|
||||
<img id="mb-pfp" src="" alt="pfp">
|
||||
<div>
|
||||
<h3 id="mb-name"></h3>
|
||||
<span id="mb-username" class="muted"></span>
|
||||
<span id="mb-id" class="chip"></span>
|
||||
</div>
|
||||
<button id="mb-close" class="btn-ghost close-btn">✕</button>
|
||||
</div>
|
||||
<div id="mb-badges" style="margin:10px 0"></div>
|
||||
|
||||
<div class="action-grid">
|
||||
<div class="action-section">
|
||||
<h4>Voice Chat</h4>
|
||||
<button class="btn-danger" id="mb-voiceban-btn">Voice Ban (Auto)</button>
|
||||
<button class="btn-action" id="mb-voiceunban-btn">Lift Voice Ban</button>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Account Ban</h4>
|
||||
<button class="btn-danger" id="mb-ban-btn">Ban</button>
|
||||
<button class="btn-action" id="mb-unban-btn">Unban</button>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Communication</h4>
|
||||
<div class="field-row">
|
||||
<input type="text" id="mb-junior-reason" placeholder="Reason (sent to player, optional)">
|
||||
</div>
|
||||
<button class="btn-danger" id="mb-junior-btn">Revoke Comms (Junior)</button>
|
||||
<button class="btn-action" id="mb-unjunior-btn">Restore Comms</button>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Session</h4>
|
||||
<button class="btn-danger" id="mb-close-game-btn">Close Game</button>
|
||||
<button class="btn-danger" id="mb-hb-clear-btn">Clear Heartbeat</button>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<h4>Profile</h4>
|
||||
<button class="btn-action" id="mb-full-profile-btn">Open Full Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="margin-top:16px">
|
||||
<h4>Message as Coach (Account ID 1)</h4>
|
||||
<div class="field-row">
|
||||
<textarea id="mb-dm-text" style="height:6vw;resize:none;flex:1" placeholder="Message to send as Coach…"></textarea>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<button class="btn-action" id="mb-coach-popup-btn">Send Popup</button>
|
||||
<button class="btn-action" id="mb-dm-send-btn">Send DM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-anticheat">
|
||||
<div class="panel-header">
|
||||
<h2>Anti-Cheat</h2>
|
||||
<div class="search-row">
|
||||
<input type="text" id="ac-search-input" placeholder="Machine ID or player ID…">
|
||||
<button id="ac-search-btn">Search</button>
|
||||
<button id="ac-list-banned-btn" class="btn-ghost">Banned Machines</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ac-note">
|
||||
A machine ID covers everyone who has played on that computer. Read the linked
|
||||
accounts before banning: a household or a shared PC puts unrelated people
|
||||
behind one ID.
|
||||
</div>
|
||||
|
||||
<div id="ac-results"></div>
|
||||
|
||||
<div class="action-grid" style="margin-top:20px">
|
||||
<div class="action-section danger-section">
|
||||
<h4>Ban Machine</h4>
|
||||
<div class="field-row">
|
||||
<label>Machine ID</label>
|
||||
<input type="text" id="ac-ban-hwid" placeholder="or leave blank">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Player ID</label>
|
||||
<input type="number" id="ac-ban-player" placeholder="bans every machine they use">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Reason</label>
|
||||
<input type="text" id="ac-ban-reason" placeholder="Required">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Days</label>
|
||||
<input type="number" id="ac-ban-days" placeholder="blank = permanent">
|
||||
<button class="btn-danger" id="ac-ban-btn">Ban Machine</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-section">
|
||||
<h4>Lift Hardware Ban</h4>
|
||||
<div class="field-row">
|
||||
<label>Machine ID</label>
|
||||
<input type="text" id="ac-unban-hwid">
|
||||
<button class="btn-action" id="ac-unban-btn">Unban</button>
|
||||
</div>
|
||||
<div class="muted">
|
||||
Accounts banned alongside the machine stay banned. Lift those from the
|
||||
Players panel, one at a time.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-server">
|
||||
<div class="panel-header">
|
||||
<h2>Server</h2>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Maintenance Broadcast</h4>
|
||||
<div class="field-row">
|
||||
<label>Minutes</label>
|
||||
<input type="number" id="db-maint-minutes" value="10" min="1">
|
||||
<button class="btn-danger" id="db-maint-btn">Send Maintenance Alert</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Announcement</h4>
|
||||
<div class="field-row">
|
||||
<label>Type</label>
|
||||
<select id="ann-type">
|
||||
<option value="Update">Update</option>
|
||||
<option value="Contest">Contest</option>
|
||||
<option value="Store">Store</option>
|
||||
<option value="Event">Event</option>
|
||||
<option value="Warning">Warning</option>
|
||||
<option value="Item">Item</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Title</label>
|
||||
<input type="text" id="ann-title" placeholder="Announcement title">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Body</label>
|
||||
<input type="text" id="ann-body" placeholder="Announcement text">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Image</label>
|
||||
<input type="text" id="ann-image" placeholder="imagename.png (optional)">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Link Type</label>
|
||||
<select id="ann-linktype">
|
||||
<option value="Url">Url</option>
|
||||
<option value="AccountId">AccountId</option>
|
||||
<option value="EventId">EventId</option>
|
||||
<option value="RoomName">RoomName</option>
|
||||
<option value="Storefront">Storefront</option>
|
||||
<option value="Item">Item</option>
|
||||
<option value="CustomAvatarItem">CustomAvatarItem</option>
|
||||
<option value="AuthorizedRecNetUrl">AuthorizedRecNetUrl</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Link Text</label>
|
||||
<input type="text" id="ann-linkname" placeholder="optional">
|
||||
<label>Link URI</label>
|
||||
<input type="text" id="ann-linkuri" placeholder="optional">
|
||||
</div>
|
||||
<button class="btn-action" id="ann-send-btn">Publish Announcement</button>
|
||||
<button class="btn-danger" id="ann-clear-btn">Clear All Announcement</button>
|
||||
<div style="margin-top:12px">
|
||||
<h4 style="margin-bottom:6px">Active Announcements</h4>
|
||||
<button class="btn-ghost" id="ann-refresh-btn" style="margin-bottom:8px">Refresh</button>
|
||||
<div id="ann-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Community Board Banner</h4>
|
||||
<div class="field-row">
|
||||
<label>Message</label>
|
||||
<input type="text" id="cbb-message" placeholder="Banner text shown on the community board">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>More Info URL</label>
|
||||
<input type="text" id="cbb-url" placeholder="https://… (optional)">
|
||||
</div>
|
||||
<button class="btn-action" id="cbb-send-btn">Update Banner</button>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Coach Chat System Prompt</h4>
|
||||
<div class="field-row">
|
||||
<label>Message</label>
|
||||
<input type="text" id="ccsp-message" placeholder="System prompt for the coach chat">
|
||||
</div>
|
||||
<button class="btn-action" id="ccsp-send-btn">Update System Prompt</button>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Room Management</h4>
|
||||
<small>Button disabled as this action is scary</small>
|
||||
<button class="btn-danger" id="db-clear-rooms-btn" disabled>Clear & Re-import Rooms</button>
|
||||
</div>
|
||||
<div class="action-section danger-section">
|
||||
<h4>Danger Zone</h4>
|
||||
<small>Button disabled as this action is scary</small>
|
||||
<button class="btn-danger" id="db-reset-btn" disabled>WIPE Database</button>
|
||||
</div>
|
||||
<h4>Coach Message</h4>
|
||||
<div class="field-row">
|
||||
<label>Message</label>
|
||||
<input type="text" id="coach-msg" value="">
|
||||
<button class="btn-danger" id="coach-btn">Send Message</button>
|
||||
</div>
|
||||
<h4>Just a reminder not to use this button, because if its spammed, people will leave Vanadium, and i also hate when this button is used by ppl</h4>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-git">
|
||||
<div class="panel-header">
|
||||
<h2>Git</h2>
|
||||
</div>
|
||||
<textarea id="git-log" style="background: black;font-family: monospace;color: white;width: 100%;height: 16rem;margin-bottom: 20px;"></textarea>
|
||||
<div class="action-section">
|
||||
<div class="field-row">
|
||||
<button class="btn-action" id="git-diff">Diff</button>
|
||||
<button class="btn-action" id="git-status">Status</button>
|
||||
<button class="btn-action" id="git-push">Commit & Push</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-session">
|
||||
<div class="panel-header">
|
||||
<h2>Session</h2>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Current Session</h4>
|
||||
<div class="info-grid" id="session-info-grid"></div>
|
||||
</div>
|
||||
<div class="action-section" style="margin-bottom:20px">
|
||||
<h4>Session Timer</h4>
|
||||
<div style="font-size:36px;font-weight:700;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:10px" id="session-big-timer">30:00</div>
|
||||
<div class="muted">Session expires 30 minutes after authorization. Actions reset the timer.</div>
|
||||
<button class="btn-action" style="margin-top:12px" id="session-extend-btn">Extend Session (Reset Timer)</button>
|
||||
</div>
|
||||
<div class="action-section danger-section">
|
||||
<h4>End Session</h4>
|
||||
<button class="btn-danger" id="session-logout-btn">Sign Out & Return to Authorization</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<div id="modal-overlay" style="display:none">
|
||||
<div id="modal-box">
|
||||
<div id="modal-title"></div>
|
||||
<div id="modal-body"></div>
|
||||
<div id="modal-actions">
|
||||
<button id="modal-confirm" class="btn-danger">Confirm</button>
|
||||
<button id="modal-cancel" class="btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="import-room-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:10001;align-items:center;justify-content:center;flex-direction:column">
|
||||
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:28px;width:420px;max-width:92vw;display:flex;flex-direction:column;gap:14px">
|
||||
<div style="font-size:16px;font-weight:700" id="import-modal-title">Meow.Net Room Importer</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="text" id="import-room-name" placeholder="Room name" style="flex:1;padding:8px 10px;background:var(--surface-alt);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:13px;outline:none">
|
||||
<button id="import-room-submit" class="btn-primary" style="white-space:nowrap">Import</button>
|
||||
</div>
|
||||
<div id="import-status" style="font-size:12px;color:var(--text-muted);display:none"></div>
|
||||
<button id="import-modal-close" class="btn-ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/vannet/Director/Asset/Director.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2697 @@
|
||||
const BASE = "/vannet/api/director";
|
||||
const SESSION_DURATION_MS = 45 * 60 * 1000;
|
||||
const REDIRECT_URL = "/vannet/Director";
|
||||
|
||||
let currentPlayer = null;
|
||||
let currentRoom = null;
|
||||
let allSkins = [];
|
||||
let sessionToken = null;
|
||||
let sessionExpiry = 0;
|
||||
let timerInterval = null;
|
||||
let sessionWs = null;
|
||||
|
||||
function safeParse(text) {
|
||||
const fixed = text.replace(/(?<!["'])(-?[0-9]{15,})(?=[ \t]*[,\]\}\r\n])/g, '"$1"');
|
||||
return JSON.parse(fixed);
|
||||
}
|
||||
|
||||
(function checkAuth() {
|
||||
sessionToken = sessionStorage.getItem("director_token");
|
||||
const expiry = parseInt(sessionStorage.getItem("director_token_expiry") || "0", 10);
|
||||
|
||||
if (!sessionToken || Date.now() > expiry) {
|
||||
sessionStorage.removeItem("director_token");
|
||||
sessionStorage.removeItem("director_token_expiry");
|
||||
window.location.href = REDIRECT_URL;
|
||||
return;
|
||||
}
|
||||
|
||||
sessionExpiry = expiry;
|
||||
const isMobile = /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth <= 768;
|
||||
const app = document.getElementById("app");
|
||||
if (isMobile) app.classList.add("mobile");
|
||||
app.style.display = "flex";
|
||||
initApp();
|
||||
startSessionTimer();
|
||||
openSessionWs();
|
||||
})();
|
||||
|
||||
function startSessionTimer() {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
timerInterval = setInterval(() => {
|
||||
const remaining = sessionExpiry - Date.now();
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
forceLogout("Your session has expired. Please re-authorize.");
|
||||
return;
|
||||
}
|
||||
const mins = Math.floor(remaining / 60000);
|
||||
const secs = Math.floor((remaining % 60000) / 1000);
|
||||
const display = `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
const v1 = document.getElementById("timer-val");
|
||||
const v2 = document.getElementById("session-big-timer");
|
||||
if (v1) v1.textContent = display;
|
||||
if (v2) v2.textContent = display;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function extendSession() {
|
||||
sessionExpiry = Date.now() + SESSION_DURATION_MS;
|
||||
sessionStorage.setItem("director_token_expiry", sessionExpiry);
|
||||
}
|
||||
|
||||
function forceLogout(msg) {
|
||||
clearInterval(timerInterval);
|
||||
if (sessionWs) { try { sessionWs.close(); } catch (_) { } }
|
||||
sessionStorage.removeItem("director_token");
|
||||
sessionStorage.removeItem("director_token_expiry");
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:999999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;color:#e8e8f0;font-family:system-ui;text-align:center;padding:24px";
|
||||
overlay.innerHTML = `<div style="font-size:36px">⬡</div><div style="font-size:16px;font-weight:600">${msg || "Session ended."}</div><div style="color:#7a7a9a;font-size:13px">Redirecting to authorization…</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
setTimeout(() => { window.location.href = REDIRECT_URL; }, 2000);
|
||||
}
|
||||
|
||||
function openSessionWs() {
|
||||
if (!sessionToken) return;
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${proto}//${location.host}/vannet/ws/director-session?token=${encodeURIComponent(sessionToken)}`;
|
||||
try {
|
||||
sessionWs = new WebSocket(wsUrl);
|
||||
sessionWs.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === "session_expired" || data.type === "force_logout") {
|
||||
forceLogout(data.message || "Session was terminated by the server.");
|
||||
}
|
||||
} catch (_) { }
|
||||
};
|
||||
sessionWs.onclose = () => {
|
||||
if (Date.now() < sessionExpiry) {
|
||||
setTimeout(openSessionWs, 3000);
|
||||
}
|
||||
};
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function gv(obj, ...keys) {
|
||||
for (const k of keys) {
|
||||
if (obj[k] !== undefined && obj[k] !== null) return obj[k];
|
||||
const lower = k.charAt(0).toLowerCase() + k.slice(1);
|
||||
if (obj[lower] !== undefined && obj[lower] !== null) return obj[lower];
|
||||
const upper = k.charAt(0).toUpperCase() + k.slice(1);
|
||||
if (obj[upper] !== undefined && obj[upper] !== null) return obj[upper];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function initApp() {
|
||||
document.querySelectorAll(".nav-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
document.querySelectorAll(".nav-btn").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.getElementById("panel-" + btn.dataset.panel).classList.add("active");
|
||||
extendSession();
|
||||
});
|
||||
});
|
||||
|
||||
setupPlayerPanel();
|
||||
setupRoomPanel();
|
||||
setupImagesPanel();
|
||||
setupDatabasePanel();
|
||||
setupModerationPanel();
|
||||
setupModBoardPanel();
|
||||
setupAntiCheatPanel();
|
||||
setupServerPanel();
|
||||
setupGitPanel();
|
||||
setupSessionPanel();
|
||||
setupRoomImport();
|
||||
loadSkins();
|
||||
renderSessionInfo();
|
||||
initUploadPreviews();
|
||||
}
|
||||
|
||||
function setupGitPanel() {
|
||||
const diffBtn = document.getElementById("git-diff");
|
||||
const statusBtn = document.getElementById("git-status");
|
||||
const pushBtn = document.getElementById("git-push");
|
||||
const log = document.getElementById("git-log");
|
||||
if (!diffBtn || !statusBtn || !pushBtn || !log) return;
|
||||
|
||||
diffBtn.addEventListener("click", async () => {
|
||||
diffBtn.disabled = true;
|
||||
log.value = "Running git diff HEAD...";
|
||||
|
||||
const res = await api("/git/diff");
|
||||
if (!res.ok) {
|
||||
const err = typeof res.data === "string"
|
||||
? res.data
|
||||
: res.data?.error || "Failed to run git diff.";
|
||||
log.value = err;
|
||||
toast("Git diff failed", "err");
|
||||
diffBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const exitCode = (typeof res.data?.exitCode === "number") ? res.data.exitCode : null;
|
||||
const output = res.data?.output;
|
||||
const body = (typeof output === "string" && output.length > 0)
|
||||
? output
|
||||
: "No diff output.";
|
||||
log.value = (exitCode !== null ? `Exit code: ${exitCode}\n\n` : "") + body;
|
||||
diffBtn.disabled = false;
|
||||
});
|
||||
|
||||
statusBtn.addEventListener("click", async () => {
|
||||
statusBtn.disabled = true;
|
||||
log.value = "Running git status -sb...";
|
||||
|
||||
const res = await api("/git/status");
|
||||
if (!res.ok) {
|
||||
const err = typeof res.data === "string"
|
||||
? res.data
|
||||
: res.data?.error || "Failed to run git status.";
|
||||
log.value = err;
|
||||
toast("Git status failed", "err");
|
||||
statusBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const exitCode = (typeof res.data?.exitCode === "number") ? res.data.exitCode : null;
|
||||
const output = res.data?.output;
|
||||
const body = (typeof output === "string" && output.length > 0)
|
||||
? output
|
||||
: "No status output.";
|
||||
log.value = (exitCode !== null ? `Exit code: ${exitCode}\n\n` : "") + body;
|
||||
statusBtn.disabled = false;
|
||||
});
|
||||
pushBtn.addEventListener("click", async () => {
|
||||
pushBtn.disabled = true;
|
||||
log.value = "Running git commit and push...";
|
||||
|
||||
const res = await api("/git/push");
|
||||
if (!res.ok) {
|
||||
const err = typeof res.data === "string"
|
||||
? res.data
|
||||
: res.data?.error || "Failed to run git push.";
|
||||
log.value = err;
|
||||
toast("Git push failed", "err");
|
||||
pushBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const exitCode = (typeof res.data?.exitCode === "number") ? res.data.exitCode : null;
|
||||
const output = res.data?.output;
|
||||
const body = (typeof output === "string" && output.length > 0)
|
||||
? output
|
||||
: "No push output.";
|
||||
log.value = (exitCode !== null ? `Exit code: ${exitCode}\n\n` : "") + body;
|
||||
pushBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function api(path, method = "GET", body = null) {
|
||||
extendSession();
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Director-Token": sessionToken || ""
|
||||
}
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
try {
|
||||
const res = await fetch(BASE + path, opts);
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
forceLogout("Your session is no longer valid.");
|
||||
return { ok: false, status: res.status, data: null };
|
||||
}
|
||||
const text = await res.text();
|
||||
try { return { ok: res.ok, status: res.status, data: safeParse(text) }; }
|
||||
catch { return { ok: res.ok, status: res.status, data: text }; }
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, data: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function apiUpload(path, formData) {
|
||||
extendSession();
|
||||
try {
|
||||
const res = await fetch(BASE + path, {
|
||||
method: "POST",
|
||||
headers: { "X-Director-Token": sessionToken || "" },
|
||||
body: formData
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
forceLogout("Your session is no longer valid.");
|
||||
return { ok: false, status: res.status, data: null };
|
||||
}
|
||||
const text = await res.text();
|
||||
try { return { ok: res.ok, status: res.status, data: safeParse(text) }; }
|
||||
catch { return { ok: res.ok, status: res.status, data: text }; }
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, data: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function toast(msg, type = "info") {
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast" + (type === "err" ? " toast-err" : type === "ok" ? " toast-ok" : "");
|
||||
el.textContent = msg;
|
||||
document.getElementById("toast-container").appendChild(el);
|
||||
requestAnimationFrame(() => el.classList.add("show"));
|
||||
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 300); }, 3200);
|
||||
}
|
||||
|
||||
function showConfirm(title, body, cb) {
|
||||
document.getElementById("modal-title").textContent = title;
|
||||
document.getElementById("modal-body").textContent = body;
|
||||
document.getElementById("modal-overlay").style.display = "flex";
|
||||
const yes = document.getElementById("modal-confirm");
|
||||
const no = document.getElementById("modal-cancel");
|
||||
const cleanup = () => {
|
||||
document.getElementById("modal-overlay").style.display = "none";
|
||||
yes.replaceWith(yes.cloneNode(true));
|
||||
no.replaceWith(no.cloneNode(true));
|
||||
};
|
||||
document.getElementById("modal-confirm").addEventListener("click", () => { cleanup(); cb(); });
|
||||
document.getElementById("modal-cancel").addEventListener("click", cleanup);
|
||||
}
|
||||
|
||||
function infoGrid(obj, containerId) {
|
||||
const el = document.getElementById(containerId);
|
||||
el.innerHTML = "";
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "info-item";
|
||||
item.innerHTML = `<div class="info-label">${k}</div><div class="info-val">${v ?? "-"}</div>`;
|
||||
el.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function pfpUrl(img) { return img ? `/imageserver/${img}` : "/imageserver/DefaultPFP.png"; }
|
||||
function roomImgUrl(img) { return img ? `/imageserver/${img}` : "/imageserver/DefaultRoomImage.png"; }
|
||||
|
||||
function setupDetailTabs(containerSelector) {
|
||||
document.querySelectorAll(containerSelector + " .dtab").forEach(tab => {
|
||||
tab.addEventListener("click", () => {
|
||||
const parent = tab.closest(".detail-pane");
|
||||
parent.querySelectorAll(".dtab").forEach(t => t.classList.remove("active"));
|
||||
parent.querySelectorAll(".dtab-content").forEach(c => c.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.getElementById(tab.dataset.tab).classList.add("active");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSessionInfo() {
|
||||
const grid = document.getElementById("session-info-grid");
|
||||
if (!grid) return;
|
||||
infoGrid({
|
||||
"Token": sessionToken ? sessionToken.substring(0, 20) + "…" : "-",
|
||||
"Expires": new Date(sessionExpiry).toLocaleTimeString(),
|
||||
"Duration": "45 minutes"
|
||||
}, "session-info-grid");
|
||||
}
|
||||
|
||||
function wireUploadPreview(inputId, previewId, isCircle) {
|
||||
const input = document.getElementById(inputId);
|
||||
const container = document.getElementById(previewId);
|
||||
if (!input || !container) return;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files[0];
|
||||
if (!file) { container.style.display = "none"; return; }
|
||||
const url = URL.createObjectURL(file);
|
||||
container.style.display = "flex";
|
||||
const img = container.querySelector("img");
|
||||
const strong = container.querySelector("strong");
|
||||
const span = container.querySelector(".upload-preview-meta span");
|
||||
img.src = url;
|
||||
img.style.borderRadius = isCircle ? "50%" : "var(--radius-sm)";
|
||||
strong.textContent = file.name;
|
||||
span.textContent = (file.size / 1024).toFixed(1) + " KB · " + file.type;
|
||||
});
|
||||
}
|
||||
|
||||
function initUploadPreviews() {
|
||||
const pfpInput = document.getElementById("pa-pfp-upload");
|
||||
if (pfpInput && !document.getElementById("pfp-upload-preview")) {
|
||||
pfpInput.insertAdjacentHTML("afterend", `
|
||||
<div class="upload-preview" id="pfp-upload-preview" style="display:none">
|
||||
<img src="" alt="preview">
|
||||
<div class="upload-preview-meta">
|
||||
<strong></strong>
|
||||
<span></span>
|
||||
<div style="color:var(--accent2);font-size:11px;margin-top:2px">Ready to upload</div>
|
||||
</div>
|
||||
</div>`);
|
||||
wireUploadPreview("pa-pfp-upload", "pfp-upload-preview", true);
|
||||
}
|
||||
|
||||
const roomInput = document.getElementById("ra-image-upload");
|
||||
if (roomInput && !document.getElementById("room-img-upload-preview")) {
|
||||
roomInput.insertAdjacentHTML("afterend", `
|
||||
<div class="upload-preview" id="room-img-upload-preview" style="display:none">
|
||||
<img src="" alt="preview">
|
||||
<div class="upload-preview-meta">
|
||||
<strong></strong>
|
||||
<span></span>
|
||||
<div style="color:var(--accent2);font-size:11px;margin-top:2px">Ready to upload</div>
|
||||
</div>
|
||||
</div>`);
|
||||
wireUploadPreview("ra-image-upload", "room-img-upload-preview", false);
|
||||
}
|
||||
}
|
||||
|
||||
function setupSessionPanel() {
|
||||
document.getElementById("session-extend-btn").addEventListener("click", () => {
|
||||
extendSession();
|
||||
renderSessionInfo();
|
||||
toast("Session extended by 45 minutes", "ok");
|
||||
});
|
||||
|
||||
document.getElementById("session-logout-btn").addEventListener("click", () => {
|
||||
showConfirm("Sign Out", "End your session and return to the authorization page?", () => {
|
||||
forceLogout("Signed out successfully.");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupPlayerPanel() {
|
||||
document.getElementById("player-search-btn").addEventListener("click", playerSearch);
|
||||
document.getElementById("player-search-input").addEventListener("keydown", e => { if (e.key === "Enter") playerSearch(); });
|
||||
document.getElementById("player-list-all-btn").addEventListener("click", playerListAll);
|
||||
document.getElementById("player-list-devs-btn").addEventListener("click", playerListDevelopers);
|
||||
document.getElementById("player-list-mods-btn").addEventListener("click", playerListModerators);
|
||||
document.getElementById("pd-close").addEventListener("click", () => {
|
||||
document.getElementById("player-detail").style.display = "none";
|
||||
currentPlayer = null;
|
||||
});
|
||||
|
||||
document.getElementById("player-create-toggle-btn").addEventListener("click", () => {
|
||||
const section = document.getElementById("player-create-section");
|
||||
section.style.display = section.style.display === "none" ? "block" : "none";
|
||||
});
|
||||
document.getElementById("pc-create-btn").addEventListener("click", createPlayer);
|
||||
|
||||
setupDetailTabs("#player-detail");
|
||||
setupPlayerActions();
|
||||
}
|
||||
|
||||
async function createPlayer() {
|
||||
const idInput = document.getElementById("pc-player-id");
|
||||
const playerId = parseInt(idInput.value, 10);
|
||||
if (!playerId || playerId <= 0) { toast("Enter a valid Player ID", "err"); return; }
|
||||
|
||||
const username = document.getElementById("pc-username").value.trim();
|
||||
const isJunior = document.getElementById("pc-junior").checked;
|
||||
|
||||
const res = await api("/players/create", "POST", {
|
||||
PlayerId: playerId,
|
||||
Username: username || null,
|
||||
IsJunior: isJunior
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
toast((res.data && res.data.error) || "Failed to create player", "err");
|
||||
return;
|
||||
}
|
||||
|
||||
toast(`Created player ${res.data.playerId} (${res.data.username})`, "ok");
|
||||
idInput.value = "";
|
||||
document.getElementById("pc-username").value = "";
|
||||
document.getElementById("pc-junior").checked = false;
|
||||
document.getElementById("player-create-section").style.display = "none";
|
||||
loadPlayerDetail(res.data.playerId);
|
||||
}
|
||||
|
||||
async function playerSearch() {
|
||||
const q = document.getElementById("player-search-input").value.trim();
|
||||
if (!q) return;
|
||||
extendSession();
|
||||
try {
|
||||
const res = await fetch(`/acc/account/search?name=${encodeURIComponent(q)}`, {
|
||||
headers: { "X-Director-Token": sessionToken || "" }
|
||||
});
|
||||
if (!res.ok) { toast("Search failed", "err"); return; }
|
||||
const data = await res.json();
|
||||
renderPlayerResults(data.Results || data.results || data);
|
||||
} catch (e) {
|
||||
toast("Search failed", "err");
|
||||
}
|
||||
}
|
||||
|
||||
async function playerListAll() {
|
||||
const res = await api("/players/search?take=100");
|
||||
if (!res.ok) { toast("Failed to load players", "err"); return; }
|
||||
renderPlayerResults(res.data.Results || res.data.results || res.data);
|
||||
}
|
||||
|
||||
async function playerListDevelopers() {
|
||||
const res = await api("/players/list-developers");
|
||||
if (!res.ok) { toast("Failed to load developers", "err"); return; }
|
||||
renderPlayerResults(res.data.Results || res.data.results || res.data);
|
||||
}
|
||||
|
||||
async function playerListModerators() {
|
||||
const res = await api("/players/list-moderators");
|
||||
if (!res.ok) { toast("Failed to load moderators", "err"); return; }
|
||||
renderPlayerResults(res.data.Results || res.data.results || res.data);
|
||||
}
|
||||
|
||||
function renderPlayerResults(players) {
|
||||
const el = document.getElementById("player-results");
|
||||
el.innerHTML = "";
|
||||
if (!players || players.length === 0) {
|
||||
el.innerHTML = '<div class="empty-state">No players found.</div>';
|
||||
return;
|
||||
}
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "results-grid";
|
||||
players.forEach(p => {
|
||||
const id = gv(p, "accountId", "playerId", "PlayerId", "AccountId");
|
||||
const name = gv(p, "displayName", "username", "DisplayName", "Username") || "Unknown";
|
||||
const username = gv(p, "username", "Username") || "?";
|
||||
const img = gv(p, "profileImage", "ProfileImage");
|
||||
const isOnline = gv(p, "isOnline") || gv(p, "heartbeat")?.isOnline || false;
|
||||
const level = gv(p, "level", "Level") || 1;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "grid-card";
|
||||
card.innerHTML = `
|
||||
<img class="grid-card-thumb" src="${pfpUrl(img)}" onerror="this.src='/imageserver/DefaultPFP.png'" style="border-radius:0">
|
||||
<div class="grid-card-badge"><span class="status-dot ${isOnline ? "status-dot-online" : "status-dot-offline"}"></span></div>
|
||||
<div class="grid-card-body">
|
||||
<div class="grid-card-name">${name}</div>
|
||||
<div class="grid-card-sub">@${username} · Lv ${level}</div>
|
||||
<div class="grid-card-sub">ID: ${id}</div>
|
||||
</div>`;
|
||||
card.addEventListener("click", () => loadPlayerDetail(id));
|
||||
grid.appendChild(card);
|
||||
});
|
||||
el.appendChild(grid);
|
||||
}
|
||||
|
||||
async function loadPlayerDetail(id) {
|
||||
const res = await api(`/players/${id}`);
|
||||
if (!res.ok) { toast("Failed to load player", "err"); return; }
|
||||
currentPlayer = res.data;
|
||||
currentPlayer._id = gv(res.data, "accountId", "playerId", "AccountId", "PlayerId");
|
||||
renderPlayerDetail(res.data);
|
||||
document.getElementById("player-detail").style.display = "block";
|
||||
document.getElementById("player-detail").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function renderPlayerDetail(p) {
|
||||
const id = p._id || gv(p, "accountId", "playerId", "AccountId", "PlayerId");
|
||||
const name = gv(p, "displayName", "username", "DisplayName", "Username") || "Unknown";
|
||||
const username = gv(p, "username", "Username") || "?";
|
||||
const img = gv(p, "profileImage", "ProfileImage");
|
||||
|
||||
document.getElementById("pd-pfp").src = pfpUrl(img);
|
||||
document.getElementById("pd-name").textContent = name;
|
||||
document.getElementById("pd-username").textContent = "@" + username;
|
||||
document.getElementById("pd-id").textContent = "ID: " + id;
|
||||
|
||||
const hb = gv(p, "heartbeat") || {};
|
||||
const bd = gv(p, "moderationBlockDetails") || {};
|
||||
const roles = gv(p, "roles", "playerRoles") || [];
|
||||
|
||||
infoGrid({
|
||||
"Player ID": id,
|
||||
"Username": gv(p, "username", "Username"),
|
||||
"Display Name": gv(p, "displayName", "DisplayName"),
|
||||
"Level": gv(p, "level", "Level"),
|
||||
"XP": gv(p, "xp", "XP"),
|
||||
"Created At": fmtDate(gv(p, "createdAt", "CreatedAt")),
|
||||
"Last Login": fmtDate(gv(p, "lastLoginAt", "LastLoginAt")),
|
||||
"Is Junior": gv(p, "isJunior", "IsJunior") ? "Yes" : "No",
|
||||
"Profile Image": img,
|
||||
"Dorm Room ID": gv(p, "dormRoomId", "DormRoomId"),
|
||||
"Bio": gv(p, "bio", "Bio") || "-",
|
||||
"Status": hb.isOnline ? "Online" : "Offline",
|
||||
"Current Room": hb.roomInstance?.roomId || "None",
|
||||
"Banned": bd.isBan || bd.IsBan ? "Yes" : "No",
|
||||
"Discord": discordLabel(p),
|
||||
"Roles": (Array.isArray(roles) ? roles : []).join(", ") || "None"
|
||||
}, "pd-info-grid");
|
||||
|
||||
const pa_username = document.getElementById("pa-username");
|
||||
if (pa_username) pa_username.value = gv(p, "username", "Username") || "";
|
||||
|
||||
renderPlayerRoles(p);
|
||||
renderPlayerBanStatus(p);
|
||||
loadPlayerDetailRooms(id);
|
||||
loadOwnedSkins(p);
|
||||
}
|
||||
|
||||
function renderPlayerRoles(p) {
|
||||
const roles = gv(p, "roles", "playerRoles", "PlayerRoles") || [];
|
||||
const grid = document.getElementById("pd-roles-grid");
|
||||
grid.innerHTML = "";
|
||||
if (!roles.length) {
|
||||
grid.innerHTML = '<span class="muted">No roles assigned.</span>';
|
||||
} else {
|
||||
roles.forEach(r => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "role-chip";
|
||||
chip.textContent = r;
|
||||
grid.appendChild(chip);
|
||||
});
|
||||
}
|
||||
document.querySelectorAll(".btn-role").forEach(btn => {
|
||||
btn.classList.toggle("has-role", roles.includes(btn.dataset.role));
|
||||
});
|
||||
}
|
||||
|
||||
function discordLabel(p) {
|
||||
const linked = gv(p, "discordLinked", "DiscordLinked");
|
||||
const uname = gv(p, "discordUsername", "DiscordUsername");
|
||||
const uid = gv(p, "discordUserId", "DiscordUserId");
|
||||
if (!linked && !uid) return "Not linked";
|
||||
const who = uname ? `@${uname}` : "";
|
||||
const idPart = uid ? `(${uid})` : "";
|
||||
return `${who} ${idPart}`.trim() || "Linked";
|
||||
}
|
||||
|
||||
function renderPlayerBanStatus(p) {
|
||||
const bd = gv(p, "moderationBlockDetails", "ModerationBlockDetails") || {};
|
||||
const el = document.getElementById("pa-ban-status");
|
||||
const isBanned = bd.isBan || bd.IsBan;
|
||||
if (!isBanned) {
|
||||
el.innerHTML = '<span class="badge badge-offline">Not Banned</span>';
|
||||
} else {
|
||||
el.innerHTML = `<span class="badge badge-banned">Banned</span>
|
||||
<div class="muted" style="margin-top:6px">Reason: ${bd.message || bd.Message || "-"}</div>
|
||||
<div class="muted">Duration: ${bd.duration || bd.Duration || "-"} days</div>`;
|
||||
}
|
||||
|
||||
const toxEl = document.getElementById("pa-toxmod-status");
|
||||
if (toxEl) {
|
||||
const tm = gv(p, "toxMod", "ToxMod") || {};
|
||||
const strikes = tm.strikes ?? tm.Strikes ?? 0;
|
||||
const banned = bd.isVoiceModAutoban || bd.IsVoiceModAutoban;
|
||||
const category = tm.activeBanCategory || tm.ActiveBanCategory;
|
||||
const expiresAt = tm.activeBanExpiresUnixTime ?? tm.ActiveBanExpiresUnixTime ?? 0;
|
||||
let html = banned
|
||||
? '<span class="badge badge-banned">Voice Banned</span>'
|
||||
: '<span class="badge badge-offline">Voice OK</span>';
|
||||
if (banned) {
|
||||
if (category) html += `<div class="muted" style="margin-top:6px">Offense: ${category}</div>`;
|
||||
html += `<div class="muted">Expires: ${expiresAt ? fmtToxModRemaining(expiresAt) : "never (manual lift required)"}</div>`;
|
||||
}
|
||||
html += `<div class="muted" style="margin-top:${banned ? 0 : 6}px">ToxMod strikes: ${strikes}</div>`;
|
||||
toxEl.innerHTML = html;
|
||||
}
|
||||
|
||||
const juniorEl = document.getElementById("pa-junior-status");
|
||||
if (juniorEl) {
|
||||
const isJr = gv(p, "isJunior", "IsJunior");
|
||||
let html = isJr
|
||||
? '<span class="badge badge-banned">Comms Restricted (Junior)</span>'
|
||||
: '<span class="badge badge-offline">Not Junior</span>';
|
||||
if (isJr) {
|
||||
const js = gv(p, "juniorStatus", "JuniorStatus") || {};
|
||||
const reason = js.reason || js.Reason;
|
||||
const by = js.setBy || js.SetBy;
|
||||
const at = js.setAt || js.SetAt;
|
||||
if (reason) html += `<div class="muted" style="margin-top:6px">Reason: ${reason}</div>`;
|
||||
if (by || at) html += `<div class="muted">By: ${by || "?"}${at ? " · " + fmtDate(at) : ""}</div>`;
|
||||
}
|
||||
juniorEl.innerHTML = html;
|
||||
}
|
||||
|
||||
const nameLockEl = document.getElementById("pa-namelock-status");
|
||||
if (nameLockEl) {
|
||||
const locked = gv(p, "nameLocked", "NameLocked");
|
||||
nameLockEl.innerHTML = locked
|
||||
? '<span class="badge badge-banned">Name Locked</span>'
|
||||
: '<span class="badge badge-offline">Name Unlocked</span>';
|
||||
}
|
||||
|
||||
const rrplusEl = document.getElementById("pa-rrplus-status");
|
||||
if (rrplusEl) {
|
||||
const has = gv(p, "recRoomPlus", "RecRoomPlus");
|
||||
rrplusEl.innerHTML = has
|
||||
? '<span class="badge badge-online">Rec Room Plus</span>'
|
||||
: '<span class="badge badge-offline">No RR+</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function fmtToxModRemaining(expiresUnixTime) {
|
||||
const secs = expiresUnixTime - Math.floor(Date.now() / 1000);
|
||||
if (secs <= 0) return "expired (lifts on next check)";
|
||||
const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600), m = Math.floor((secs % 3600) / 60);
|
||||
const parts = [];
|
||||
if (d) parts.push(d + "d");
|
||||
if (h) parts.push(h + "h");
|
||||
parts.push(m + "m");
|
||||
return `${new Date(expiresUnixTime * 1000).toLocaleString()} (in ${parts.join(" ")})`;
|
||||
}
|
||||
|
||||
async function loadPlayerDetailRooms(playerId) {
|
||||
const res = await api(`/players/${playerId}/rooms`);
|
||||
const el = document.getElementById("pd-rooms-list");
|
||||
el.innerHTML = "";
|
||||
const rooms = res.ok ? (res.data.rooms || res.data.Rooms || res.data || []) : [];
|
||||
if (!rooms.length) {
|
||||
el.innerHTML = '<div class="empty-state">No rooms found.</div>';
|
||||
return;
|
||||
}
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "pd-rooms-grid";
|
||||
rooms.forEach(r => {
|
||||
const rid = gv(r, "roomId", "RoomId");
|
||||
const rname = gv(r, "name", "Name", "displayName", "DisplayName") || "Unknown";
|
||||
const rimg = gv(r, "imageName", "ImageName");
|
||||
const visits = gv(r, "stats", "Stats")?.visitCount ?? gv(r, "stats", "Stats")?.VisitCount ?? 0;
|
||||
const card = document.createElement("div");
|
||||
card.className = "grid-card";
|
||||
card.innerHTML = `
|
||||
<img class="grid-card-thumb" src="${roomImgUrl(rimg)}" style="border-radius:0" onerror="this.src='/imageserver/DefaultRoomImage.png'">
|
||||
<div class="grid-card-body">
|
||||
<div class="grid-card-name">${rname}</div>
|
||||
<div class="grid-card-sub">ID: ${rid} · ${visits} visits</div>
|
||||
</div>`;
|
||||
card.addEventListener("click", () => { loadRoomDetail(rid); switchPanel("rooms"); });
|
||||
grid.appendChild(card);
|
||||
});
|
||||
el.appendChild(grid);
|
||||
}
|
||||
|
||||
function setupPlayerActions() {
|
||||
const pid = () => currentPlayer?._id;
|
||||
|
||||
document.getElementById("pa-rename-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const name = document.getElementById("pa-username").value.trim();
|
||||
if (!name) return;
|
||||
const res = await api(`/players/${pid()}/rename`, "POST", { username: name });
|
||||
if (res.ok) { toast("Renamed to " + name, "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Rename failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-clearname-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/clear-name`, "POST", {});
|
||||
if (res.ok) { toast("Name cleared: " + (res.data?.newName || "?"), "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-namelock-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/name-lock`, "POST", { locked: true });
|
||||
if (res.ok) { toast("Name locked", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-nameunlock-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/name-lock`, "POST", { locked: false });
|
||||
if (res.ok) { toast("Name unlocked", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-bio-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const bio = document.getElementById("pa-bio").value;
|
||||
const res = await api(`/players/${pid()}/set-bio`, "POST", { bio });
|
||||
if (res.ok) { toast("Bio updated", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-password-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const password = document.getElementById("pa-password").value;
|
||||
const res = await api(`/players/${pid()}/set-password`, "POST", { password });
|
||||
if (res.ok) { toast("Password updated", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
|
||||
document.getElementById("pa-pfp-set-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const img = document.getElementById("pa-pfp-name").value.trim();
|
||||
if (!img) return;
|
||||
const res = await api(`/players/${pid()}/set-pfp`, "POST", { imageName: img });
|
||||
if (res.ok) { toast("PFP updated", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-pfp-upload-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const fileInput = document.getElementById("pa-pfp-upload");
|
||||
if (!fileInput.files[0]) { toast("Select a file first", "err"); return; }
|
||||
const fd = new FormData();
|
||||
fd.append("file", fileInput.files[0]);
|
||||
const res = await apiUpload(`/players/${pid()}/upload-pfp`, fd);
|
||||
if (res.ok) {
|
||||
toast("PFP uploaded", "ok");
|
||||
fileInput.value = "";
|
||||
const preview = document.getElementById("pfp-upload-preview");
|
||||
if (preview) preview.style.display = "none";
|
||||
await loadPlayerDetail(pid());
|
||||
} else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-pfp-reset-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Reset PFP", "Reset this player's profile picture to default?", async () => {
|
||||
const res = await api(`/players/${pid()}/reset-pfp`, "POST", {});
|
||||
if (res.ok) { toast("PFP reset", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-level-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const level = parseInt(document.getElementById("pa-level").value);
|
||||
const xpVal = document.getElementById("pa-xp").value;
|
||||
const xp = xpVal ? parseInt(xpVal) : null;
|
||||
if (!level) return;
|
||||
const res = await api(`/players/${pid()}/set-level`, "POST", { level, xp });
|
||||
if (res.ok) { toast("Level set to " + level, "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-platformid-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const platformId = document.getElementById("pa-platformid").value.trim();
|
||||
if (!platformId) return;
|
||||
const res = await api(`/players/${pid()}/set-platform-id`, "POST", { platformId });
|
||||
if (res.ok) { toast("Platform ID updated", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-rrplus-add-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/rrplus`, "POST", { enabled: true });
|
||||
if (res.ok) { toast("Rec Room Plus granted", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-rrplus-remove-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/rrplus`, "POST", { enabled: false });
|
||||
if (res.ok) { toast("Rec Room Plus removed", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-junior-set-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const reason = document.getElementById("pa-junior-reason").value.trim() || null;
|
||||
const res = await api(`/players/${pid()}/set-junior`, "POST", { isJunior: true, reason });
|
||||
if (res.ok) {
|
||||
toast("Set as Junior - player notified", "ok");
|
||||
document.getElementById("pa-junior-reason").value = "";
|
||||
await loadPlayerDetail(pid());
|
||||
}
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-junior-unset-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/set-junior`, "POST", { isJunior: false });
|
||||
if (res.ok) { toast("Junior removed", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-toxmod-ban-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const category = document.getElementById("pa-toxmod-category").value;
|
||||
const durVal = document.getElementById("pa-toxmod-duration").value;
|
||||
const note = document.getElementById("pa-toxmod-note").value.trim();
|
||||
const loud = document.getElementById("pa-toxmod-mode").value === "loud";
|
||||
const res = await api(`/players/${pid()}/toxmod/ban`, "POST", {
|
||||
category,
|
||||
note: note || null,
|
||||
durationSeconds: durVal ? parseInt(durVal) : null,
|
||||
loud
|
||||
});
|
||||
if (res.ok) { toast(loud ? "ToxMod voice ban issued (loud)" : "ToxMod voice ban issued (quiet)", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-toxmod-unban-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/toxmod/unban`, "POST", { resetStrikes: false });
|
||||
if (res.ok) { toast("Voice ban lifted", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-toxmod-reset-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/toxmod/unban`, "POST", { resetStrikes: true });
|
||||
if (res.ok) { toast("Voice ban lifted and strikes reset", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-hb-clear-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Clear Heartbeat", "Clear heartbeat for this player?", async () => {
|
||||
const res = await api(`/players/${pid()}/heartbeat-clear`, "POST", {});
|
||||
if (res.ok) { toast("Heartbeat cleared", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-hb-view-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/heartbeat`);
|
||||
if (res.ok) toast(JSON.stringify(res.data), "info");
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-bring-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const username = document.getElementById("pa-bring-username").value.trim();
|
||||
if (!username) return;
|
||||
const res = await api(`/players/${pid()}/force-bring`, "POST", { username });
|
||||
if (res.ok) toast("Force brought to " + username + "'s room", "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-transfer-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
const target = parseInt(document.getElementById("pa-transfer-target").value);
|
||||
if (!target) return;
|
||||
const keepCoOwner = document.getElementById("pa-transfer-coowner").checked;
|
||||
const extra = keepCoOwner ? " The original owner will stay as co-owner." : "";
|
||||
showConfirm("Transfer Rooms", `Transfer all rooms from player ${pid()} to player ${target}?${extra}`, async () => {
|
||||
const res = await api(`/players/${pid()}/transfer-rooms`, "POST", { newPlayerId: target, keepCoOwner });
|
||||
if (res.ok) toast(keepCoOwner ? "Rooms transferred, old owner kept as co-owner" : "Rooms transferred", "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-sendws-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const raw = (document.getElementById("pa-sendws")?.value || "").trim();
|
||||
if (!raw) { toast("Enter a JSON payload first", "err"); return; }
|
||||
try { JSON.parse(raw); } catch { toast("Invalid JSON", "err"); return; }
|
||||
const res = await api(`/players/${pid()}/send-ws-json`, "POST", { Json: raw });
|
||||
if (res.ok) toast("Sent to player websocket", "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-repair-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Repair Account", "Repair account for this player?", async () => {
|
||||
const res = await api(`/players/${pid()}/repair`, "POST", {});
|
||||
if (res.ok) { toast("Account repaired", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-del-images-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Delete All Images", "Delete ALL images for this player? This cannot be undone.", async () => {
|
||||
const res = await api(`/players/${pid()}/delete-all-images`, "POST", {});
|
||||
if (res.ok) toast("Images deleted", "ok");
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-del-rooms-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Delete All Rooms", "Delete ALL rooms for this player? This cannot be undone.", async () => {
|
||||
const res = await api(`/players/${pid()}/delete-all-rooms`, "POST", {});
|
||||
if (res.ok) toast("Rooms deleted", "ok");
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-delete-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Delete Account", "Permanently delete this account? This cannot be undone.", async () => {
|
||||
const res = await api(`/players/${pid()}/delete`, "DELETE");
|
||||
if (res.ok) {
|
||||
toast("Account deleted", "ok");
|
||||
document.getElementById("player-detail").style.display = "none";
|
||||
currentPlayer = null;
|
||||
} else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".btn-role").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const role = btn.dataset.role;
|
||||
const hasRole = btn.classList.contains("has-role");
|
||||
const action = hasRole ? "remove" : "add";
|
||||
const res = await api(`/players/${pid()}/roles`, "POST", { role, action });
|
||||
if (res.ok) { toast("Role " + action + "ed: " + role, "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-influencer-add").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/influencer`, "POST", { action: "add" });
|
||||
if (res.ok) { toast("Influencer granted", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-influencer-remove").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const res = await api(`/players/${pid()}/influencer`, "POST", { action: "remove" });
|
||||
if (res.ok) { toast("Influencer removed", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-ban-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
const reason = document.getElementById("pa-ban-reason").value.trim() || "Banned by admin.";
|
||||
const duration = parseInt(document.getElementById("pa-ban-duration").value) || 99999;
|
||||
showConfirm("Ban Player", "Ban this player?", async () => {
|
||||
const res = await api(`/players/${pid()}/ban`, "POST", { reason, duration });
|
||||
if (res.ok) { toast("Player banned", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-perm-ban-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
const reason = document.getElementById("pa-ban-reason").value.trim() || "Banned by admin.";
|
||||
showConfirm("Permanently Ban Player", "Permanently ban this player? Duration will be set to 2147483647 days.", async () => {
|
||||
const res = await api(`/players/${pid()}/ban`, "POST", { reason, duration: 2147483647 });
|
||||
if (res.ok) { toast("Player permanently banned", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-unban-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Unban Player", "Unban this player?", async () => {
|
||||
const res = await api(`/players/${pid()}/unban`, "POST", {});
|
||||
if (res.ok) { toast("Player unbanned", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-close-game-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Close Game", "Close this users game?", async () => {
|
||||
const res = await api(`/players/${pid()}/close-game`, "POST", {});
|
||||
if (res.ok) toast("Close game sent", "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-logout-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
showConfirm("Logout Player", "Logout this user?", async () => {
|
||||
const res = await api(`/players/${pid()}/logout`, "POST", {});
|
||||
if (res.ok) toast("Logged Out (dummy message)", "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("pa-player-coach-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const message = document.getElementById("pa-player-coach-msg").value.trim();
|
||||
if (!message) { toast("Enter a message", "err"); return; }
|
||||
const res = await api(`/players/${pid()}/send-coach-message`, "POST", { message });
|
||||
if (res.ok) {
|
||||
toast("Coach message sent", "ok");
|
||||
document.getElementById("pa-player-coach-msg").value = "";
|
||||
} else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-token-btn").addEventListener("click", async () => {
|
||||
if (!pid()) return;
|
||||
const amount = parseInt(document.getElementById("pa-token-amount").value);
|
||||
const balanceType = parseInt(document.getElementById("pa-token-type").value);
|
||||
if (isNaN(amount)) return;
|
||||
const res = await api(`/players/${pid()}/set-tokens`, "POST", { amount, balanceType });
|
||||
if (res.ok) { toast("Tokens set to " + amount, "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("pa-skin-search").addEventListener("input", function () {
|
||||
renderSkinList(this.value);
|
||||
});
|
||||
|
||||
document.getElementById("pa-rmd-btn").addEventListener("click", () => {
|
||||
if (!pid()) return;
|
||||
const reporterVal = document.getElementById("pa-rmd-reporter").value.trim();
|
||||
const timeoutVal = document.getElementById("pa-rmd-timeoutstartedat").value;
|
||||
const body = {
|
||||
reportCategory: parseInt(document.getElementById("pa-rmd-category").value),
|
||||
duration: parseInt(document.getElementById("pa-rmd-duration").value) || 0,
|
||||
gameSessionId: parseInt(document.getElementById("pa-rmd-gamesessionid").value) || 0,
|
||||
message: document.getElementById("pa-rmd-message").value.trim() || null,
|
||||
playerIdReporter: reporterVal ? parseInt(reporterVal) : null,
|
||||
voteKickReason: document.getElementById("pa-rmd-votekickreason").value.trim() || null,
|
||||
timeoutStartedAt: timeoutVal ? new Date(timeoutVal).toISOString() : null,
|
||||
associatedAccountUsername: document.getElementById("pa-rmd-assocusername").value.trim() || null,
|
||||
isBan: document.getElementById("pa-rmd-isban").value === "true",
|
||||
isHostKick: document.getElementById("pa-rmd-ishostkick").value === "true",
|
||||
isVoiceModAutoban: document.getElementById("pa-rmd-isvoicemodautoban").value === "true",
|
||||
isDeviceBan: document.getElementById("pa-rmd-isdeviceban").value === "true",
|
||||
isWarning: document.getElementById("pa-rmd-iswarning").value === "true",
|
||||
bootToDorm: document.getElementById("pa-rmd-boottodorm").value === "true"
|
||||
};
|
||||
showConfirm("Apply Raw Mod Details", "Apply raw moderation details to this player?", async () => {
|
||||
const res = await api(`/players/${pid()}/raw-mod-details`, "POST", body);
|
||||
if (res.ok) { toast("Raw mod details applied", "ok"); await loadPlayerDetail(pid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSkins() {
|
||||
const res = await api("/skins");
|
||||
if (res.ok) allSkins = res.data || [];
|
||||
}
|
||||
|
||||
function renderSkinList(filter = "") {
|
||||
const el = document.getElementById("pd-skin-list");
|
||||
el.innerHTML = "";
|
||||
const skins = filter ? allSkins.filter(s => (s.friendlyName || s.FriendlyName)?.toLowerCase().includes(filter.toLowerCase())) : allSkins.slice(0, 60);
|
||||
skins.forEach(skin => {
|
||||
const name = gv(skin, "friendlyName", "FriendlyName") || "Unknown";
|
||||
const item = document.createElement("div");
|
||||
item.className = "skin-item";
|
||||
item.innerHTML = `<span>${name}</span><button class="btn-action" style="font-size:11px;padding:4px 8px">Give</button>`;
|
||||
item.querySelector("button").addEventListener("click", async () => {
|
||||
if (!currentPlayer?._id) return;
|
||||
const res = await api(`/players/${currentPlayer._id}/give-skin`, "POST", { skin: name, action: "add" });
|
||||
if (res.ok) { toast("Skin granted: " + name, "ok"); loadOwnedSkins(currentPlayer); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
el.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function loadOwnedSkins(p) {
|
||||
const extra = gv(p, "playerExtra", "PlayerExtra") || {};
|
||||
const owned = gv(extra, "ownedEquipment", "OwnedEquipment") || gv(p, "ownedEquipment", "OwnedEquipment") || [];
|
||||
const el = document.getElementById("pd-owned-list");
|
||||
el.innerHTML = "";
|
||||
owned.forEach(s => {
|
||||
const name = gv(s, "friendlyName", "FriendlyName") || gv(s, "prefabName", "PrefabName") || "Unknown";
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "owned-skin-chip";
|
||||
chip.innerHTML = `${name} <span>✕</span>`;
|
||||
chip.addEventListener("click", () => {
|
||||
showConfirm("Remove Skin", "Remove " + name + "?", async () => {
|
||||
const res = await api(`/players/${currentPlayer._id}/give-skin`, "POST", { skin: name, action: "remove" });
|
||||
if (res.ok) { toast("Skin removed", "ok"); await loadPlayerDetail(currentPlayer._id); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
el.appendChild(chip);
|
||||
});
|
||||
if (!owned.length) el.innerHTML = '<span class="muted">No skins owned.</span>';
|
||||
}
|
||||
|
||||
function setupRoomPanel() {
|
||||
document.getElementById("room-search-btn").addEventListener("click", roomSearch);
|
||||
document.getElementById("room-search-input").addEventListener("keydown", e => { if (e.key === "Enter") roomSearch(); });
|
||||
document.getElementById("room-hot-btn").addEventListener("click", roomLoadHot);
|
||||
document.getElementById("rd-close").addEventListener("click", () => {
|
||||
document.getElementById("room-detail").style.display = "none";
|
||||
currentRoom = null;
|
||||
});
|
||||
|
||||
setupDetailTabs("#room-detail");
|
||||
setupRoomActions();
|
||||
}
|
||||
|
||||
async function roomSearch() {
|
||||
const q = document.getElementById("room-search-input").value.trim();
|
||||
if (!q) return;
|
||||
if (/^\d+$/.test(q)) {
|
||||
const res = await api(`/rooms/${BigInt(q)}`);
|
||||
if (res.ok) { renderRoomResults([res.data]); return; }
|
||||
toast("Room not found", "err"); return;
|
||||
}
|
||||
extendSession();
|
||||
try {
|
||||
const res = await api(`/rooms/search?q=${encodeURIComponent(q)}&skip=0&take=30`);
|
||||
if (!res.ok) { toast("Search failed", "err"); return; }
|
||||
renderRoomResults(res.data.Results || res.data.results || res.data);
|
||||
} catch (e) {
|
||||
toast("Search failed", "err");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function roomLoadHot() {
|
||||
const res = await api("/rooms/hot?take=50");
|
||||
if (!res.ok) { toast("Failed to load rooms", "err"); return; }
|
||||
renderRoomResults(res.data.Results || res.data.results || res.data);
|
||||
}
|
||||
|
||||
function renderRoomResults(rooms) {
|
||||
const el = document.getElementById("room-results");
|
||||
el.innerHTML = "";
|
||||
if (!rooms || rooms.length === 0) {
|
||||
el.innerHTML = '<div class="empty-state">No rooms found.</div>';
|
||||
return;
|
||||
}
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "results-grid";
|
||||
rooms.forEach(r => {
|
||||
const rid = gv(r, "roomId", "RoomId");
|
||||
const rname = gv(r, "name", "Name", "displayName", "DisplayName") || "Unknown";
|
||||
const rimg = gv(r, "imageName", "ImageName");
|
||||
const creator = gv(r, "creatorAccountId", "CreatorAccountId") || "?";
|
||||
const statsObj = gv(r, "stats", "Stats") || {};
|
||||
const visits = statsObj.visitCount ?? statsObj.VisitCount ?? 0;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "grid-card";
|
||||
card.innerHTML = `
|
||||
<img class="grid-card-thumb" src="${roomImgUrl(rimg)}" style="border-radius:0;border-bottom:1px solid var(--border)" onerror="this.src='/imageserver/DefaultRoomImage.png'">
|
||||
<div class="grid-card-visits">${visits} visits</div>
|
||||
<div class="grid-card-body">
|
||||
<div class="grid-card-name">${rname}</div>
|
||||
<div class="grid-card-sub">ID: ${rid} · Creator: ${creator}</div>
|
||||
</div>`;
|
||||
card.addEventListener("click", () => loadRoomDetail(rid));
|
||||
grid.appendChild(card);
|
||||
});
|
||||
el.appendChild(grid);
|
||||
}
|
||||
|
||||
async function loadRoomDetail(id) {
|
||||
const res = await api(`/rooms/${id}`);
|
||||
if (!res.ok) { toast("Failed to load room", "err"); return; }
|
||||
currentRoom = res.data;
|
||||
currentRoom._id = gv(res.data, "roomId", "RoomId");
|
||||
renderRoomDetail(res.data);
|
||||
document.getElementById("room-detail").style.display = "block";
|
||||
document.getElementById("room-detail").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function populateSubroomSelect(r) {
|
||||
const sel = document.getElementById("ra-blob-subroom-select");
|
||||
sel.innerHTML = '<option value="">Select SubRoom…</option>';
|
||||
const subs = gv(r, "subRooms", "SubRooms") || [];
|
||||
subs.forEach(s => {
|
||||
const sid = String(gv(s, "subRoomId", "SubRoomId"));
|
||||
const sname = gv(s, "name", "Name") || "SubRoom";
|
||||
const opt = document.createElement("option");
|
||||
opt.value = sid;
|
||||
opt.textContent = `${sname} (ID: ${sid})`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderRoomDetail(r) {
|
||||
const rid = r._id || gv(r, "roomId", "RoomId");
|
||||
const rname = gv(r, "displayName", "DisplayName", "name", "Name") || "Unknown";
|
||||
const rimg = gv(r, "imageName", "ImageName");
|
||||
const creator = gv(r, "creatorAccountId", "CreatorAccountId") || "?";
|
||||
const creatorUser = gv(r, "creatorUsername", "CreatorUsername");
|
||||
|
||||
document.getElementById("rd-img").src = roomImgUrl(rimg);
|
||||
document.getElementById("rd-name").textContent = rname;
|
||||
document.getElementById("rd-id").textContent = "ID: " + rid;
|
||||
document.getElementById("rd-creator").textContent = "Creator ID: " + creator + (creatorUser ? " (@" + creatorUser + ")" : "");
|
||||
|
||||
const statsObj = gv(r, "stats", "Stats") || {};
|
||||
const subRooms = gv(r, "subRooms", "SubRooms") || [];
|
||||
|
||||
infoGrid({
|
||||
"Room ID": rid,
|
||||
"Name": gv(r, "name", "Name"),
|
||||
"Display Name": gv(r, "displayName", "DisplayName"),
|
||||
"Description": gv(r, "description", "Description"),
|
||||
"Creator ID": creator,
|
||||
"Creator Username": creatorUser || "-",
|
||||
"Max Players": gv(r, "maxPlayers", "MaxPlayers"),
|
||||
"Accessibility": accessibilityName(gv(r, "accessibility", "Accessibility")),
|
||||
"State": gv(r, "state", "State"),
|
||||
"Age Rating": gv(r, "ageRating", "AgeRating"),
|
||||
"Is RRO": (gv(r, "isRRO", "IsRRO")) ? "Yes" : "No",
|
||||
"Cloning": (gv(r, "cloningAllowed", "CloningAllowed")) ? "Allowed" : "Disabled",
|
||||
"Supports Mobile": (gv(r, "supportsMobile", "SupportsMobile")) ? "Yes" : "No",
|
||||
"Created At": fmtDate(gv(r, "createdAt", "CreatedAt")),
|
||||
"SubRooms": subRooms.length
|
||||
}, "rd-info-grid");
|
||||
|
||||
const statsGrid = document.getElementById("rd-stats-grid");
|
||||
if (statsGrid) {
|
||||
statsGrid.innerHTML = "";
|
||||
const statsData = {
|
||||
"Visits": statsObj.visitCount ?? statsObj.VisitCount ?? 0,
|
||||
"Cheers": statsObj.cheerCount ?? statsObj.CheerCount ?? 0,
|
||||
"Favorites": statsObj.favoriteCount ?? statsObj.FavoriteCount ?? 0
|
||||
};
|
||||
for (const [k, v] of Object.entries(statsData)) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "info-item";
|
||||
item.innerHTML = `<div class="info-label">${k}</div><div class="info-val">${v}</div>`;
|
||||
statsGrid.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
renderRoomTags(r);
|
||||
renderSubRooms(r);
|
||||
populateSubroomSelect(r);
|
||||
|
||||
const ra_name = document.getElementById("ra-name");
|
||||
const ra_desc = document.getElementById("ra-desc");
|
||||
if (ra_name) ra_name.value = gv(r, "name", "Name") || "";
|
||||
if (ra_desc) ra_desc.value = gv(r, "description", "Description") || "";
|
||||
|
||||
const accessSel = document.getElementById("ra-access");
|
||||
const currAccess = gv(r, "accessibility", "Accessibility");
|
||||
if (accessSel && currAccess !== null) accessSel.value = currAccess;
|
||||
|
||||
const cloneSel = document.getElementById("ra-cloning");
|
||||
if (cloneSel) cloneSel.value = (gv(r, "cloningAllowed", "CloningAllowed")) ? "true" : "false";
|
||||
|
||||
const ageSel = document.getElementById("ra-agerating");
|
||||
if (ageSel) ageSel.value = gv(r, "ageRating", "AgeRating") ?? 0;
|
||||
|
||||
const maxEl = document.getElementById("ra-maxplayers");
|
||||
if (maxEl) maxEl.value = gv(r, "maxPlayers", "MaxPlayers") ?? 8;
|
||||
|
||||
const mobileSel = document.getElementById("ra-mobile");
|
||||
if (mobileSel) mobileSel.value = (gv(r, "supportsMobile", "SupportsMobile")) ? "true" : "false";
|
||||
|
||||
const screensSel = document.getElementById("ra-screens");
|
||||
if (screensSel) screensSel.value = (gv(r, "supportsScreens", "SupportsScreens")) ? "true" : "false";
|
||||
|
||||
const walkvrSel = document.getElementById("ra-walkvr");
|
||||
if (walkvrSel) walkvrSel.value = (gv(r, "supportsWalkVR", "SupportsWalkVR")) ? "true" : "false";
|
||||
|
||||
const teleportvrSel = document.getElementById("ra-teleportvr");
|
||||
if (teleportvrSel) teleportvrSel.value = (gv(r, "supportsTeleportVR", "SupportsTeleportVR")) ? "true" : "false";
|
||||
|
||||
const juniorsSel = document.getElementById("ra-juniors");
|
||||
if (juniorsSel) juniorsSel.value = (gv(r, "supportsJuniors", "SupportsJuniors")) ? "true" : "false";
|
||||
}
|
||||
|
||||
const TAG_TYPE_NAMES = { 0: "General", 1: "Auto", 2: "AG Only", 3: "Banned" };
|
||||
|
||||
function renderRoomTags(r) {
|
||||
const tags = gv(r, "tags", "Tags") || [];
|
||||
const el = document.getElementById("rd-current-tags");
|
||||
el.innerHTML = "";
|
||||
if (!tags.length) {
|
||||
el.innerHTML = '<span class="muted">No tags.</span>';
|
||||
return;
|
||||
}
|
||||
tags.forEach(t => {
|
||||
const tagName = gv(t, "tag", "Tag") || t;
|
||||
const tagType = t.type ?? t.Type ?? 0;
|
||||
const typeLabel = TAG_TYPE_NAMES[tagType] || `Type ${tagType}`;
|
||||
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "tag-chip";
|
||||
chip.innerHTML = `${tagName}<span class="tag-type-badge type-${tagType}">${typeLabel}</span><button class="tag-remove-btn" title="Remove tag" data-tag="${tagName}">✕</button>`;
|
||||
chip.querySelector(".tag-remove-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const tag = e.currentTarget.dataset.tag;
|
||||
const rid = currentRoom?._id;
|
||||
if (!rid) return;
|
||||
const res = await api(`/rooms/${rid}/tags`, "POST", { tags: [tag], action: "remove" });
|
||||
if (res.ok) { toast("Tag removed", "ok"); await loadRoomDetail(rid); }
|
||||
else toast("Failed to remove tag", "err");
|
||||
});
|
||||
el.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSubRooms(r) {
|
||||
const subs = gv(r, "subRooms", "SubRooms") || [];
|
||||
const rid = r._id || gv(r, "roomId", "RoomId");
|
||||
const el = document.getElementById("rd-subrooms-list");
|
||||
el.innerHTML = "";
|
||||
if (!subs.length) {
|
||||
el.innerHTML = '<div class="empty-state">No subrooms.</div>';
|
||||
return;
|
||||
}
|
||||
subs.forEach(s => {
|
||||
const sid = gv(s, "subRoomId", "SubRoomId");
|
||||
const card = document.createElement("div");
|
||||
card.className = "subroom-card";
|
||||
card.innerHTML = `
|
||||
<div>
|
||||
<div class="sr-name">${gv(s, "name", "Name") || "SubRoom"}</div>
|
||||
<div class="sr-meta">ID: ${sid} · Max: ${gv(s, "maxPlayers", "MaxPlayers")} · ${accessibilityName(gv(s, "accessibility", "Accessibility"))}</div>
|
||||
<div class="sr-meta">DataBlob: ${gv(s, "dataBlob", "DataBlob") || "none"}</div>
|
||||
<div class="sr-meta sr-instances" data-subroom="${sid}">Instances: …</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<button class="btn-danger sr-refresh-btn" data-subroom="${sid}">Refresh All Public Instances</button>
|
||||
<button class="btn-action sr-makeprivate-btn" data-subroom="${sid}">Make All Instances Private</button>
|
||||
<button class="btn-action sr-makepublic-btn" data-subroom="${sid}">Make All Instances Public</button>
|
||||
</div>`;
|
||||
|
||||
card.querySelector(".sr-refresh-btn").addEventListener("click", () => {
|
||||
const srName = gv(s, "name", "Name") || `SubRoom ${sid}`;
|
||||
showConfirm(
|
||||
"Refresh All Public Instances",
|
||||
`Everyone in a public instance of "${srName}" will be moved into a brand new instance. Players currently together stay together. Private instances are untouched.`,
|
||||
async () => {
|
||||
const res = await api(`/rooms/${rid}/refresh-instances`, "POST", { subRoomId: sid });
|
||||
if (res.ok && res.data?.success) {
|
||||
const moved = res.data.playersMoved ?? 0;
|
||||
const inst = res.data.instancesRefreshed ?? 0;
|
||||
toast(inst ? `Refreshed ${inst} instance(s), moved ${moved} player(s)` : "No public instances to refresh", inst ? "ok" : "info");
|
||||
loadSubRoomInstanceCounts(rid);
|
||||
} else {
|
||||
toast(res.data?.error || "Failed to refresh instances", "err");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
card.querySelector(".sr-makeprivate-btn").addEventListener("click", () => {
|
||||
const srName = gv(s, "name", "Name") || `SubRoom ${sid}`;
|
||||
showConfirm(
|
||||
"Make All Instances Private",
|
||||
`Every public instance of "${srName}" will be switched to private, and players inside will stay together. Already-private instances are untouched.`,
|
||||
async () => {
|
||||
const res = await setSubRoomInstancesPrivacy(rid, sid, true);
|
||||
if (res.ok && res.data?.success) {
|
||||
const changed = res.data.instancesChanged ?? 0;
|
||||
toast(changed ? `Made ${changed} instance(s) private` : "No public instances to change", changed ? "ok" : "info");
|
||||
loadSubRoomInstanceCounts(rid);
|
||||
} else {
|
||||
toast(res.data?.error || "Failed to update instances", "err");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
card.querySelector(".sr-makepublic-btn").addEventListener("click", () => {
|
||||
const srName = gv(s, "name", "Name") || `SubRoom ${sid}`;
|
||||
showConfirm(
|
||||
"Make All Instances Public",
|
||||
`Every private instance of "${srName}" will be switched to public, and players inside will stay together. Already-public instances are untouched.`,
|
||||
async () => {
|
||||
const res = await setSubRoomInstancesPrivacy(rid, sid, false);
|
||||
if (res.ok && res.data?.success) {
|
||||
const changed = res.data.instancesChanged ?? 0;
|
||||
toast(changed ? `Made ${changed} instance(s) public` : "No private instances to change", changed ? "ok" : "info");
|
||||
loadSubRoomInstanceCounts(rid);
|
||||
} else {
|
||||
toast(res.data?.error || "Failed to update instances", "err");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
el.appendChild(card);
|
||||
});
|
||||
|
||||
loadSubRoomInstanceCounts(rid);
|
||||
}
|
||||
|
||||
async function setSubRoomInstancesPrivacy(roomId, subRoomId, isPrivate) {
|
||||
return api(`/rooms/${roomId}/set-instances-privacy`, "POST", { subRoomId, isPrivate });
|
||||
}
|
||||
|
||||
async function loadSubRoomInstanceCounts(roomId) {
|
||||
const cells = document.querySelectorAll("#rd-subrooms-list .sr-instances");
|
||||
if (!cells.length) return;
|
||||
|
||||
const res = await api(`/rooms/${roomId}/instances`);
|
||||
if (!res.ok) {
|
||||
cells.forEach(c => { c.textContent = "Instances: unavailable"; });
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = {};
|
||||
(res.data?.subRooms || []).forEach(x => { stats[String(x.subRoomId)] = x; });
|
||||
|
||||
cells.forEach(c => {
|
||||
const st = stats[c.dataset.subroom];
|
||||
const pub = st?.publicInstances ?? 0;
|
||||
const pubPlayers = st?.publicPlayers ?? 0;
|
||||
const priv = st?.privateInstances ?? 0;
|
||||
const privPlayers = st?.privatePlayers ?? 0;
|
||||
c.textContent = `Public: ${pub} instance${pub === 1 ? "" : "s"} (${pubPlayers} player${pubPlayers === 1 ? "" : "s"}) · Private: ${priv} instance${priv === 1 ? "" : "s"} (${privPlayers} player${privPlayers === 1 ? "" : "s"})`;
|
||||
|
||||
const card = c.closest(".subroom-card");
|
||||
const refreshBtn = card?.querySelector(".sr-refresh-btn");
|
||||
if (refreshBtn) refreshBtn.disabled = pub === 0;
|
||||
const makePrivateBtn = card?.querySelector(".sr-makeprivate-btn");
|
||||
if (makePrivateBtn) makePrivateBtn.disabled = pub === 0;
|
||||
const makePublicBtn = card?.querySelector(".sr-makepublic-btn");
|
||||
if (makePublicBtn) makePublicBtn.disabled = priv === 0;
|
||||
});
|
||||
}
|
||||
|
||||
function setupRoomImport() {
|
||||
function wireImporter(triggerBtnId, endpoint, title) {
|
||||
const modal = document.getElementById("import-room-modal");
|
||||
const status = document.getElementById("import-status");
|
||||
const nameInput = document.getElementById("import-room-name");
|
||||
const submitBtn = document.getElementById("import-room-submit");
|
||||
|
||||
const resetModal = () => {
|
||||
modal.style.display = "none";
|
||||
status.style.display = "none";
|
||||
status.textContent = "";
|
||||
nameInput.value = "";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.onclick = null;
|
||||
};
|
||||
|
||||
document.getElementById(triggerBtnId).addEventListener("click", () => {
|
||||
modal.style.display = "flex";
|
||||
document.getElementById("import-modal-title").textContent = title;
|
||||
|
||||
submitBtn.onclick = async () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { toast("Room name is required", "err"); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
status.style.display = "block";
|
||||
status.textContent = "Importing…";
|
||||
status.style.color = "var(--text-muted)";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/vannet/api/director/rooms/${endpoint}?name=${encodeURIComponent(name)}`, {
|
||||
method: "POST",
|
||||
headers: { "X-Director-Token": sessionToken || "" }
|
||||
});
|
||||
|
||||
const data = await res.text().then(t => { try { return safeParse(t); } catch (_) { return {}; } }).catch(() => ({}));
|
||||
|
||||
if (res.ok && data.success) {
|
||||
status.textContent = `Done - ${data.subRoomsImported} subroom(s) imported. Room ID: ${data.roomId} (${data.name})`;
|
||||
status.style.color = "var(--success)";
|
||||
toast("Room imported successfully", "ok");
|
||||
nameInput.value = "";
|
||||
} else {
|
||||
status.textContent = "Error: " + (data.error || res.status);
|
||||
status.style.color = "var(--danger-hover)";
|
||||
toast("Import failed: " + (data.error || res.status), "err");
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = "Network error: " + e.message;
|
||||
status.style.color = "var(--danger-hover)";
|
||||
}
|
||||
|
||||
submitBtn.disabled = false;
|
||||
};
|
||||
});
|
||||
|
||||
document.getElementById("import-modal-close").addEventListener("click", resetModal);
|
||||
modal.addEventListener("click", (e) => { if (e.target === modal) resetModal(); });
|
||||
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submitBtn.click(); });
|
||||
}
|
||||
|
||||
wireImporter("room-import-btn", "import-meownet", "Meow.Net Room Importer");
|
||||
wireImporter("room-import-epicquest-btn", "import-epicquest", "EpicQuest Room Importer");
|
||||
}
|
||||
|
||||
function setupRoomActions() {
|
||||
const rid = () => currentRoom?._id;
|
||||
|
||||
document.getElementById("ra-name-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const name = document.getElementById("ra-name").value.trim();
|
||||
if (!name) return;
|
||||
const res = await api(`/rooms/${rid()}/set-name`, "POST", { name });
|
||||
if (res.ok) { toast("Name updated", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-desc-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const description = document.getElementById("ra-desc").value;
|
||||
const res = await api(`/rooms/${rid()}/set-description`, "POST", { description });
|
||||
if (res.ok) { toast("Description updated", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-image-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const imageName = document.getElementById("ra-image").value.trim();
|
||||
if (!imageName) return;
|
||||
const res = await api(`/rooms/${rid()}/set-image`, "POST", { imageName });
|
||||
if (res.ok) { toast("Image updated", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-image-upload-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const fileInput = document.getElementById("ra-image-upload");
|
||||
if (!fileInput.files[0]) { toast("Select a file first", "err"); return; }
|
||||
const fd = new FormData();
|
||||
fd.append("file", fileInput.files[0]);
|
||||
const res = await apiUpload(`/rooms/${rid()}/upload-image`, fd);
|
||||
if (res.ok) {
|
||||
toast("Image uploaded", "ok");
|
||||
fileInput.value = "";
|
||||
const preview = document.getElementById("room-img-upload-preview");
|
||||
if (preview) preview.style.display = "none";
|
||||
await loadRoomDetail(rid());
|
||||
} else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-image-reset-btn").addEventListener("click", () => {
|
||||
if (!rid()) return;
|
||||
showConfirm("Reset Image", "Reset room image to default?", async () => {
|
||||
const res = await api(`/rooms/${rid()}/reset-image`, "POST", {});
|
||||
if (res.ok) { toast("Image reset", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("ra-access-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const accessibility = parseInt(document.getElementById("ra-access").value);
|
||||
const res = await api(`/rooms/${rid()}/set-accessibility`, "POST", { accessibility });
|
||||
if (res.ok) { toast("Accessibility updated", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-cloning-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const allowed = document.getElementById("ra-cloning").value === "true";
|
||||
const res = await api(`/rooms/${rid()}/set-cloning`, "POST", { allowed });
|
||||
if (res.ok) { toast("Cloning updated", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-restrictions-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const supportsScreens = document.getElementById("ra-screens").value === "true";
|
||||
const supportsWalkVR = document.getElementById("ra-walkvr").value === "true";
|
||||
const supportsTeleportVR = document.getElementById("ra-teleportvr").value === "true";
|
||||
const supportsJuniors = document.getElementById("ra-juniors").value === "true";
|
||||
const supportsMobile = document.getElementById("ra-mobile").value === "true";
|
||||
const res = await api(`/rooms/${rid()}/set-restrictions`, "POST", { supportsScreens, supportsWalkVR, supportsTeleportVR, supportsJuniors, supportsMobile });
|
||||
if (res.ok) { toast("Platform settings saved", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-agerating-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const ageRating = parseInt(document.getElementById("ra-agerating").value);
|
||||
const res = await api(`/rooms/${rid()}/set-age-rating`, "POST", { ageRating });
|
||||
if (res.ok) { toast("Age rating updated", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-maxplayers-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const maxPlayers = parseInt(document.getElementById("ra-maxplayers").value);
|
||||
if (!maxPlayers) return;
|
||||
const res = await api(`/rooms/${rid()}/set-max-players`, "POST", { maxPlayers });
|
||||
if (res.ok) { toast("Max players updated", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-owner-btn").addEventListener("click", () => {
|
||||
if (!rid()) return;
|
||||
const newOwnerId = parseInt(document.getElementById("ra-owner").value);
|
||||
if (!newOwnerId) return;
|
||||
showConfirm("Transfer Ownership", `Transfer room ${rid()} to player ${newOwnerId}?`, async () => {
|
||||
const res = await api(`/rooms/${rid()}/change-owner`, "POST", { newOwnerId });
|
||||
if (res.ok) { toast("Ownership transferred", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("ra-role-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const playerId = parseInt(document.getElementById("ra-role-player").value);
|
||||
const role = parseInt(document.getElementById("ra-role-val").value);
|
||||
if (!playerId) return;
|
||||
const res = await api(`/rooms/${rid()}/set-role`, "POST", { playerId, role });
|
||||
if (res.ok) { toast("Role set", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-beta-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const res = await api(`/rooms/${rid()}/toggle-beta`, "POST", {});
|
||||
if (res.ok) { toast("Beta tag toggled", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-blob-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const subRoomId = document.getElementById("ra-blob-subroom-select").value;
|
||||
const datablob = document.getElementById("ra-blob-name").value.trim();
|
||||
if (!subRoomId || !datablob) { toast("Select a subroom and enter a datablob filename", "err"); return; }
|
||||
const res = await api(`/rooms/${rid()}/change-datablob`, "POST", { subRoomId: subRoomId.toString(), datablob });
|
||||
if (res.ok) { toast("DataBlob imported", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-blob-upload").addEventListener("change", async () => {
|
||||
if (!rid()) return;
|
||||
const subRoomId = document.getElementById("ra-blob-subroom-select").value;
|
||||
const fileInput = document.getElementById("ra-blob-upload");
|
||||
if (!subRoomId) { toast("Select a subroom first", "err"); fileInput.value = ""; return; }
|
||||
if (!fileInput.files[0]) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", fileInput.files[0]);
|
||||
fd.append("subRoomId", subRoomId.toString());
|
||||
const res = await apiUpload(`/rooms/${rid()}/upload-datablob`, fd);
|
||||
if (res.ok) {
|
||||
toast("DataBlob uploaded: " + res.data?.fileName, "ok");
|
||||
fileInput.value = "";
|
||||
await loadRoomDetail(rid());
|
||||
} else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-tag-add-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const tag = document.getElementById("ra-tag-input").value.trim();
|
||||
if (!tag) return;
|
||||
const type = parseInt(document.getElementById("ra-tag-type").value);
|
||||
const res = await api(`/rooms/${rid()}/tags`, "POST", { tags: [tag], action: "add", type });
|
||||
if (res.ok) { toast("Tag added", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-tag-remove-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const tag = document.getElementById("ra-tag-input").value.trim();
|
||||
if (!tag) return;
|
||||
const res = await api(`/rooms/${rid()}/tags`, "POST", { tags: [tag], action: "remove" });
|
||||
if (res.ok) { toast("Tag removed", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
document.getElementById("ra-set-modern-persistence-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const res = await api(`/rooms/${rid()}/set-modern-persistence`, "POST", {});
|
||||
if (res.ok) { toast("Modern persistence values set", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-roomtype-btn").addEventListener("click", async () => {
|
||||
if (!rid()) return;
|
||||
const roomType = document.getElementById("ra-roomtype").value === "true";
|
||||
const res = await api(`/rooms/${rid()}/set-room-type`, "POST", { roomType });
|
||||
if (res.ok) { toast("Room type updated", "ok"); await loadRoomDetail(rid()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
|
||||
document.getElementById("ra-delete-btn").addEventListener("click", () => {
|
||||
if (!rid()) return;
|
||||
showConfirm("Delete Room", "Permanently delete this room? This cannot be undone.", async () => {
|
||||
const res = await api(`/rooms/${rid()}/delete`, "DELETE");
|
||||
if (res.ok) {
|
||||
toast("Room deleted", "ok");
|
||||
document.getElementById("room-detail").style.display = "none";
|
||||
currentRoom = null;
|
||||
} else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let currentImage = null;
|
||||
let lastLoadedImages = [];
|
||||
let showPrivateImages = false;
|
||||
|
||||
const IMAGE_TYPE_NAMES = {
|
||||
0: "None",
|
||||
1: "Share Camera",
|
||||
2: "Outfit Thumbnail",
|
||||
3: "Room Thumbnail",
|
||||
4: "Profile Thumbnail",
|
||||
5: "Invention Thumbnail",
|
||||
6: "Player Event Thumbnail",
|
||||
7: "Room Load Screen"
|
||||
};
|
||||
|
||||
function imageTypeName(v) {
|
||||
return IMAGE_TYPE_NAMES[v] ?? ("Type " + v);
|
||||
}
|
||||
|
||||
function setupImagesPanel() {
|
||||
document.getElementById("img-show-private").addEventListener("change", function () {
|
||||
showPrivateImages = this.checked;
|
||||
renderImages(lastLoadedImages);
|
||||
});
|
||||
|
||||
document.getElementById("img-search-btn").addEventListener("click", async () => {
|
||||
const pid = document.getElementById("img-player-id").value.trim();
|
||||
if (!pid) return;
|
||||
const res = await api(`/Images/player/${pid}`);
|
||||
if (!res.ok) { toast("Failed to load images", "err"); return; }
|
||||
lastLoadedImages = res.data || [];
|
||||
renderImages(lastLoadedImages);
|
||||
});
|
||||
|
||||
document.getElementById("img-global-btn").addEventListener("click", async () => {
|
||||
const res = await api("/Images/global");
|
||||
if (!res.ok) { toast("Failed", "err"); return; }
|
||||
lastLoadedImages = res.data || [];
|
||||
renderImages(lastLoadedImages);
|
||||
});
|
||||
|
||||
document.getElementById("img-detail-close").addEventListener("click", closeImageDetail);
|
||||
document.getElementById("img-detail-overlay").addEventListener("click", e => {
|
||||
if (e.target === document.getElementById("img-detail-overlay")) closeImageDetail();
|
||||
});
|
||||
|
||||
document.getElementById("imgd-access-btn").addEventListener("click", async () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
const val = parseInt(document.getElementById("imgd-access-select").value);
|
||||
const res = await api(`/Images/${id}/set-accessibility`, "POST", { accessibility: val });
|
||||
if (res.ok) { toast("Accessibility updated", "ok"); await reloadCurrentImage(id); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("imgd-lock-btn").addEventListener("click", async () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
const res = await api(`/Images/${id}/set-accessibility-lock`, "POST", { locked: true });
|
||||
if (res.ok) { toast("Accessibility locked", "ok"); await reloadCurrentImage(id); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("imgd-unlock-btn").addEventListener("click", async () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
const res = await api(`/Images/${id}/set-accessibility-lock`, "POST", { locked: false });
|
||||
if (res.ok) { toast("Accessibility unlocked", "ok"); await reloadCurrentImage(id); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("imgd-desc-btn").addEventListener("click", async () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
const desc = document.getElementById("imgd-desc-input").value;
|
||||
const res = await api(`/Images/${id}/set-description`, "POST", { description: desc || null });
|
||||
if (res.ok) { toast("Description updated", "ok"); await reloadCurrentImage(id); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("imgd-reset-cheers-btn").addEventListener("click", () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
showConfirm("Reset Cheers", "Reset all cheers on this image to 0?", async () => {
|
||||
const res = await api(`/Images/${id}/reset-cheers`, "POST", {});
|
||||
if (res.ok) { toast("Cheers reset", "ok"); await reloadCurrentImage(id); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("imgd-json-btn").addEventListener("click", () => {
|
||||
const block = document.getElementById("imgd-json-block");
|
||||
if (block.style.display === "none") {
|
||||
block.textContent = JSON.stringify(currentImage, null, 2);
|
||||
block.style.display = "block";
|
||||
document.getElementById("imgd-json-btn").textContent = "Hide JSON";
|
||||
} else {
|
||||
block.style.display = "none";
|
||||
document.getElementById("imgd-json-btn").textContent = "View Raw JSON";
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("imgd-devlock-btn").addEventListener("click", async () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
showConfirm("Enable Dev Lock", "Lock this image so only developers can modify it bro?", async () => {
|
||||
const res = await api(`/Images/${id}/set-dev-lock`, "POST", { devLocked: true });
|
||||
if (res.ok) { toast("Dev lock enabled", "ok"); await reloadCurrentImage(id); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("imgd-devunlock-btn").addEventListener("click", async () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
const res = await api(`/Images/${id}/set-dev-lock`, "POST", { devLocked: false });
|
||||
if (res.ok) { toast("Dev lock removed", "ok"); await reloadCurrentImage(id); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("imgd-transfer-btn").addEventListener("click", () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
const newPlayerId = parseInt(document.getElementById("imgd-transfer-id").value);
|
||||
if (!newPlayerId) { toast("Enter a valid player ID", "err"); return; }
|
||||
showConfirm("Transfer Image", `Transfer image ${id} to player ${newPlayerId}? The image will appear in their gallery.`, async () => {
|
||||
const res = await api(`/Images/${id}/transfer`, "POST", { newPlayerId });
|
||||
if (res.ok) {
|
||||
toast("Image transferred to player " + newPlayerId, "ok");
|
||||
document.getElementById("imgd-transfer-id").value = "";
|
||||
await reloadCurrentImage(id);
|
||||
} else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("imgd-copy-name-btn").addEventListener("click", () => {
|
||||
if (!currentImage) return;
|
||||
const name = gv(currentImage, "imageName", "ImageName") ?? "";
|
||||
if (!name) { toast("No file name available", "err"); return; }
|
||||
navigator.clipboard.writeText(name).then(() => {
|
||||
toast("Copied: " + name, "ok");
|
||||
}).catch(() => {
|
||||
toast("Copy failed - try manually", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("imgd-delete-btn").addEventListener("click", () => {
|
||||
if (!currentImage) return;
|
||||
const id = currentImage.id ?? currentImage.Id ?? currentImage.savedImageId ?? currentImage.SavedImageId;
|
||||
showConfirm("Delete Image", "Permanently delete this image and all its cheers? This cannot be undone.", async () => {
|
||||
const res = await api(`/Images/${id}/delete`, "POST", {});
|
||||
if (res.ok) {
|
||||
toast("Image deleted", "ok");
|
||||
closeImageDetail();
|
||||
const card = document.querySelector(`.img-card[data-id="${id}"]`);
|
||||
if (card) card.remove();
|
||||
} else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadCurrentImage(id) {
|
||||
const res = await api(`/Images/${id}`);
|
||||
if (res.ok) {
|
||||
currentImage = res.data;
|
||||
renderImageDetail(res.data);
|
||||
}
|
||||
}
|
||||
|
||||
function closeImageDetail() {
|
||||
document.getElementById("img-detail-overlay").style.display = "none";
|
||||
currentImage = null;
|
||||
}
|
||||
|
||||
async function openImageDetail(imgData) {
|
||||
const id = imgData.id ?? imgData.Id ?? imgData.savedImageId ?? imgData.SavedImageId;
|
||||
const res = await api(`/Images/${id}`);
|
||||
const full = res.ok ? res.data : imgData;
|
||||
currentImage = full;
|
||||
renderImageDetail(full);
|
||||
const overlay = document.getElementById("img-detail-overlay");
|
||||
overlay.style.display = "flex";
|
||||
}
|
||||
|
||||
function renderImageDetail(img) {
|
||||
const name = gv(img, "imageName", "ImageName");
|
||||
const id = img.id ?? img.Id ?? img.savedImageId ?? img.SavedImageId ?? "?";
|
||||
const playerId = gv(img, "playerId", "PlayerId") ?? "?";
|
||||
const roomId = gv(img, "roomId", "RoomId") ?? "?";
|
||||
const type = img.type ?? img.Type ?? 0;
|
||||
const access = img.accessibility ?? img.Accessibility ?? 0;
|
||||
const locked = img.accessibilityLocked ?? img.AccessibilityLocked ?? false;
|
||||
const cheerCount = img.cheerCount ?? img.CheerCount ?? 0;
|
||||
const commentCount = img.commentCount ?? img.CommentCount ?? 0;
|
||||
const createdAt = img.createdAt ?? img.CreatedAt;
|
||||
const desc = img.description ?? img.Description;
|
||||
const tagged = img.taggedPlayerIds ?? img.TaggedPlayerIds ?? [];
|
||||
const playerEventId = gv(img, "playerEventId", "PlayerEventId");
|
||||
|
||||
document.getElementById("imgd-preview").src = `/imageserver/${name}`;
|
||||
|
||||
const typeBadge = document.getElementById("imgd-type-badge");
|
||||
typeBadge.textContent = imageTypeName(type);
|
||||
|
||||
const accessBadge = document.getElementById("imgd-access-badge");
|
||||
if (access === 1) {
|
||||
accessBadge.textContent = "Public";
|
||||
accessBadge.style.background = "rgba(39,174,96,0.18)";
|
||||
accessBadge.style.border = "1px solid rgba(39,174,96,0.4)";
|
||||
accessBadge.style.color = "var(--success)";
|
||||
} else {
|
||||
accessBadge.textContent = "Private";
|
||||
accessBadge.style.background = "rgba(192,57,43,0.12)";
|
||||
accessBadge.style.border = "1px solid rgba(192,57,43,0.3)";
|
||||
accessBadge.style.color = "var(--danger)";
|
||||
}
|
||||
|
||||
infoGrid({
|
||||
"Image ID": id,
|
||||
"Player ID": playerId,
|
||||
"Room ID": roomId,
|
||||
"Player Event ID": playerEventId ?? "-",
|
||||
"Cheers": cheerCount,
|
||||
"Comments": commentCount,
|
||||
"Created": fmtDate(createdAt),
|
||||
"Description": desc ?? "-"
|
||||
}, "imgd-info-grid");
|
||||
|
||||
const accessSel = document.getElementById("imgd-access-select");
|
||||
accessSel.value = String(access);
|
||||
|
||||
const lockStatus = document.getElementById("imgd-lock-status");
|
||||
lockStatus.innerHTML = locked
|
||||
? '<span class="badge badge-banned">Locked - player cannot change accessibility</span>'
|
||||
: '<span class="badge badge-offline">Unlocked</span>';
|
||||
|
||||
document.getElementById("imgd-desc-input").value = desc ?? "";
|
||||
|
||||
const devLocked = img.devLocked ?? img.DevLocked ?? false;
|
||||
const devLockEl = document.getElementById("imgd-devlock-status");
|
||||
if (devLockEl) {
|
||||
devLockEl.innerHTML = devLocked
|
||||
? '<span class="badge badge-banned">Dev Locked - only developers can modify this image</span>'
|
||||
: '<span class="badge badge-offline">No Dev Lock</span>';
|
||||
}
|
||||
|
||||
const filenameEl = document.getElementById("imgd-filename-display");
|
||||
if (filenameEl) {
|
||||
filenameEl.textContent = name ? name : "-";
|
||||
}
|
||||
|
||||
const taggedEl = document.getElementById("imgd-tagged-players");
|
||||
taggedEl.innerHTML = "";
|
||||
if (tagged.length) {
|
||||
tagged.forEach(tpid => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "chip";
|
||||
chip.textContent = tpid;
|
||||
chip.style.cursor = "pointer";
|
||||
chip.title = "Open player profile";
|
||||
chip.addEventListener("click", () => {
|
||||
closeImageDetail();
|
||||
loadPlayerDetail(tpid);
|
||||
switchPanel("players");
|
||||
});
|
||||
taggedEl.appendChild(chip);
|
||||
});
|
||||
} else {
|
||||
taggedEl.textContent = "No tagged players.";
|
||||
}
|
||||
|
||||
const roomEl = document.getElementById("imgd-room-info");
|
||||
if (roomId && roomId !== "?" && roomId !== 0) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "btn-ghost";
|
||||
btn.style.fontSize = "12px";
|
||||
btn.textContent = "Room ID: " + roomId + " - Open Room";
|
||||
btn.addEventListener("click", () => {
|
||||
closeImageDetail();
|
||||
loadRoomDetail(roomId);
|
||||
switchPanel("rooms");
|
||||
});
|
||||
roomEl.innerHTML = "";
|
||||
roomEl.appendChild(btn);
|
||||
} else {
|
||||
roomEl.textContent = "No room associated.";
|
||||
}
|
||||
|
||||
const cheerEl = document.getElementById("imgd-cheer-info");
|
||||
cheerEl.textContent = cheerCount + " cheer" + (cheerCount === 1 ? "" : "s");
|
||||
|
||||
const jsonBlock = document.getElementById("imgd-json-block");
|
||||
jsonBlock.style.display = "none";
|
||||
document.getElementById("imgd-json-btn").textContent = "View Raw JSON";
|
||||
}
|
||||
|
||||
function renderImages(images) {
|
||||
const el = document.getElementById("img-results");
|
||||
el.innerHTML = "";
|
||||
|
||||
const sorted = [...images].sort((a, b) => {
|
||||
const da = new Date(a.createdAt ?? a.CreatedAt ?? 0).getTime();
|
||||
const db = new Date(b.createdAt ?? b.CreatedAt ?? 0).getTime();
|
||||
return db - da;
|
||||
});
|
||||
|
||||
const filtered = showPrivateImages
|
||||
? sorted
|
||||
: sorted.filter(img => (img.accessibility ?? img.Accessibility ?? 0) === 1);
|
||||
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = '<div class="empty-state">No images found.</div>';
|
||||
return;
|
||||
}
|
||||
filtered.forEach(img => {
|
||||
const name = gv(img, "imageName", "ImageName", "fileName", "FileName");
|
||||
const id = img.savedImageId ?? img.SavedImageId ?? img.id ?? img.Id ?? "?";
|
||||
const playerId = gv(img, "playerId", "PlayerId") ?? "?";
|
||||
const access = img.accessibility ?? img.Accessibility ?? 0;
|
||||
const type = img.type ?? img.Type ?? img.savedImageType ?? img.SavedImageType ?? 0;
|
||||
const cheerCount = img.cheerCount ?? img.CheerCount ?? 0;
|
||||
const locked = img.accessibilityLocked ?? img.AccessibilityLocked ?? false;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "img-card";
|
||||
card.dataset.id = id;
|
||||
card.innerHTML = `
|
||||
<img src="/imageserver/${name}" onerror="this.src='/imageserver/DefaultPFP.png'" loading="lazy">
|
||||
<div class="img-card-meta">
|
||||
<div style="font-weight:700;color:var(--text);margin-bottom:2px">${imageTypeName(type)}</div>
|
||||
<div>ID: ${id} · Player: ${playerId}</div>
|
||||
<div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap">
|
||||
<span style="padding:2px 5px;border-radius:3px;font-size:10px;background:${access === 1 ? "rgba(39,174,96,0.18)" : "rgba(192,57,43,0.12)"};color:${access === 1 ? "var(--success)" : "var(--danger)"};">${access === 1 ? "Public" : "Private"}</span>
|
||||
${locked ? '<span style="padding:2px 5px;border-radius:3px;font-size:10px;background:rgba(0,0,0,0.1)">Locked</span>' : ""}
|
||||
${cheerCount > 0 ? `<span style="padding:2px 5px;border-radius:3px;font-size:10px;background:rgba(242,101,34,0.12);color:var(--accent)">♥ ${cheerCount}</span>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
card.addEventListener("click", () => openImageDetail(img));
|
||||
el.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function setupDatabasePanel() {
|
||||
const refreshBtn = document.getElementById("db-refresh-btn");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", loadDbStats);
|
||||
|
||||
const execBtn = document.getElementById("db-exec-btn");
|
||||
if (execBtn) execBtn.addEventListener("click", async () => {
|
||||
const expression = document.getElementById("db-expr-input").value.trim();
|
||||
if (!expression) return;
|
||||
const res = await api("/db/execute", "POST", { expression });
|
||||
const block = document.getElementById("db-exec-result");
|
||||
block.classList.add("visible");
|
||||
block.textContent = res.ok ? JSON.stringify(res.data.result ?? res.data, null, 2) : (res.data?.error || "Error: " + res.status);
|
||||
});
|
||||
|
||||
const giftBtn = document.getElementById("db-gift-tokens-btn");
|
||||
if (giftBtn) giftBtn.addEventListener("click", () => {
|
||||
const amount = parseInt(document.getElementById("db-gift-amount").value);
|
||||
const message = document.getElementById("db-gift-msg").value.trim() || null;
|
||||
if (!amount || amount <= 0) { toast("Enter a valid amount", "err"); return; }
|
||||
showConfirm("Gift All Players", `This lags the server a bit and also risky (Amount: ${amount})`, async () => {
|
||||
const res = await api("/players/gift-all-tokens", "POST", { amount, message });
|
||||
if (res.ok) toast(`Gifted ${amount} tokens to ${res.data?.playersGifted ?? "?"} players`, "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
loadDbStats();
|
||||
}
|
||||
|
||||
async function loadDbStats() {
|
||||
const res = await api("/db/info");
|
||||
if (!res.ok) return;
|
||||
const d = res.data;
|
||||
document.getElementById("dbs-players").textContent = d.players ?? "-";
|
||||
document.getElementById("dbs-rooms").textContent = d.rooms ?? "-";
|
||||
document.getElementById("dbs-saves").textContent = d.saves ?? "-";
|
||||
document.getElementById("dbs-inventions").textContent = d.inventions ?? "-";
|
||||
}
|
||||
|
||||
function setupModerationPanel() {
|
||||
document.getElementById("mod-ban-btn").addEventListener("click", () => {
|
||||
const id = parseInt(document.getElementById("mod-ban-id").value);
|
||||
const reason = document.getElementById("mod-ban-reason").value.trim() || "Banned by admin.";
|
||||
const duration = parseInt(document.getElementById("mod-ban-days").value) || 99999;
|
||||
if (!id) return;
|
||||
showConfirm("Ban Player", `Ban player ${id}?`, async () => {
|
||||
const res = await api(`/players/${id}/ban`, "POST", { reason, duration });
|
||||
if (res.ok) toast("Player " + id + " banned", "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("mod-unban-btn").addEventListener("click", () => {
|
||||
const id = parseInt(document.getElementById("mod-unban-id").value);
|
||||
if (!id) return;
|
||||
showConfirm("Unban Player", `Unban player ${id}?`, async () => {
|
||||
const res = await api(`/players/${id}/unban`, "POST", {});
|
||||
if (res.ok) toast("Player " + id + " unbanned", "ok");
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("mod-shove-btn").addEventListener("click", async () => {
|
||||
const pid = parseInt(document.getElementById("mod-shove-player").value);
|
||||
const roomId = parseInt(document.getElementById("mod-shove-room").value);
|
||||
const subRoomIdVal = document.getElementById("mod-shove-subroom").value;
|
||||
const subRoomId = subRoomIdVal ? parseInt(subRoomIdVal) : null;
|
||||
const instanceId = document.getElementById("mod-shove-instance").value.trim() || null;
|
||||
if (!pid || !roomId) return;
|
||||
const res = await api(`/players/${pid}/shove`, "POST", { roomId, subRoomId, instanceId });
|
||||
if (res.ok) toast(`Player ${pid} shoved to room ${roomId}`, "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("mod-hb-btn").addEventListener("click", () => {
|
||||
const id = parseInt(document.getElementById("mod-hb-id").value);
|
||||
if (!id) return;
|
||||
showConfirm("Clear Heartbeat", `Clear heartbeat for player ${id}?`, async () => {
|
||||
const res = await api(`/players/${id}/heartbeat-clear`, "POST", {});
|
||||
if (res.ok) toast("Heartbeat cleared", "ok");
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("mod-dragall-btn").addEventListener("click", () => {
|
||||
const roomId = parseInt(document.getElementById("mod-dragall-room").value);
|
||||
const subRoomIdVal = document.getElementById("mod-dragall-subroom").value;
|
||||
const subRoomId = subRoomIdVal ? parseInt(subRoomIdVal) : null;
|
||||
const instanceId = document.getElementById("mod-dragall-instance").value.trim() || null;
|
||||
if (!roomId) return;
|
||||
showConfirm("Drag All Online", `Drag all online players to room ${roomId}?`, async () => {
|
||||
const res = await api("/players/shove-all", "POST", { roomId, subRoomId, instanceId });
|
||||
if (res.ok) toast(`Dragged ${res.data?.playersShoved ?? "?"} players to room ${roomId}`, "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let modBoardPlayer = null;
|
||||
|
||||
function setupModBoardPanel() {
|
||||
const mbId = () => modBoardPlayer?._id;
|
||||
|
||||
async function mbLoadPlayer(id) {
|
||||
const res = await api(`/players/${id}`);
|
||||
if (!res.ok) { toast("Player not found", "err"); return; }
|
||||
modBoardPlayer = res.data;
|
||||
modBoardPlayer._id = gv(res.data, "accountId", "playerId", "AccountId", "PlayerId");
|
||||
renderModBoardCard(res.data);
|
||||
}
|
||||
|
||||
async function mbLookup() {
|
||||
const q = document.getElementById("mb-lookup-input").value.trim();
|
||||
if (!q) return;
|
||||
if (/^\d+$/.test(q)) { await mbLoadPlayer(q); return; }
|
||||
const res = await api(`/players/search?q=${encodeURIComponent(q)}&take=50`);
|
||||
if (!res.ok) { toast("Search failed", "err"); return; }
|
||||
const results = res.data.Results || res.data.results || res.data || [];
|
||||
const exact = results.find(r => (gv(r, "username", "Username") || "").toLowerCase() === q.toLowerCase());
|
||||
const pick = exact || results[0];
|
||||
if (!pick) { toast("No player found", "err"); return; }
|
||||
await mbLoadPlayer(gv(pick, "accountId", "playerId", "AccountId", "PlayerId"));
|
||||
}
|
||||
|
||||
document.getElementById("mb-lookup-btn").addEventListener("click", mbLookup);
|
||||
document.getElementById("mb-lookup-input").addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") mbLookup();
|
||||
});
|
||||
|
||||
document.getElementById("mb-close").addEventListener("click", () => {
|
||||
modBoardPlayer = null;
|
||||
document.getElementById("mb-player-card").style.display = "none";
|
||||
});
|
||||
|
||||
document.getElementById("mb-voiceban-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
const res = await api(`/players/${mbId()}/toxmod/ban`, "POST", { category: "Other", loud: false });
|
||||
if (res.ok) { toast("Voice ban issued", "ok"); await mbLoadPlayer(mbId()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("mb-voiceunban-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
const res = await api(`/players/${mbId()}/toxmod/unban`, "POST", { resetStrikes: false });
|
||||
if (res.ok) { toast("Voice ban lifted", "ok"); await mbLoadPlayer(mbId()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("mb-ban-btn").addEventListener("click", () => {
|
||||
if (!mbId()) return;
|
||||
showConfirm("Ban Player", `Ban player ${mbId()}?`, async () => {
|
||||
const res = await api(`/players/${mbId()}/ban`, "POST", { reason: "Banned by a moderator.", duration: 99999 });
|
||||
if (res.ok) { toast("Player banned", "ok"); await mbLoadPlayer(mbId()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("mb-unban-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
const res = await api(`/players/${mbId()}/unban`, "POST", {});
|
||||
if (res.ok) { toast("Player unbanned", "ok"); await mbLoadPlayer(mbId()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("mb-junior-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
const reason = document.getElementById("mb-junior-reason").value.trim() || null;
|
||||
const res = await api(`/players/${mbId()}/set-junior`, "POST", { isJunior: true, reason });
|
||||
if (res.ok) {
|
||||
toast("Comms revoked - player notified", "ok");
|
||||
document.getElementById("mb-junior-reason").value = "";
|
||||
await mbLoadPlayer(mbId());
|
||||
}
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("mb-unjunior-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
const res = await api(`/players/${mbId()}/set-junior`, "POST", { isJunior: false });
|
||||
if (res.ok) { toast("Comms restored", "ok"); await mbLoadPlayer(mbId()); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("mb-close-game-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
const res = await api(`/players/${mbId()}/close-game`, "POST", {});
|
||||
if (res.ok) toast("Game closed", "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("mb-hb-clear-btn").addEventListener("click", () => {
|
||||
if (!mbId()) return;
|
||||
showConfirm("Clear Heartbeat", `Clear heartbeat for player ${mbId()}?`, async () => {
|
||||
const res = await api(`/players/${mbId()}/heartbeat-clear`, "POST", {});
|
||||
if (res.ok) { toast("Heartbeat cleared", "ok"); await mbLoadPlayer(mbId()); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("mb-full-profile-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
await loadPlayerDetail(mbId());
|
||||
switchPanel("players");
|
||||
});
|
||||
|
||||
document.getElementById("mb-dm-send-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
const msg = document.getElementById("mb-dm-text").value.trim();
|
||||
if (!msg) { toast("Enter a message first", "err"); return; }
|
||||
const res = await api(`/players/${mbId()}/coach-dm`, "POST", { message: msg });
|
||||
if (res.ok) { toast("DM sent as Coach", "ok"); document.getElementById("mb-dm-text").value = ""; }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("mb-coach-popup-btn").addEventListener("click", async () => {
|
||||
if (!mbId()) return;
|
||||
const msg = document.getElementById("mb-dm-text").value.trim();
|
||||
if (!msg) { toast("Enter a message first", "err"); return; }
|
||||
const res = await api(`/players/${mbId()}/send-coach-message`, "POST", { message: msg });
|
||||
if (res.ok) { toast("Popup sent as Coach", "ok"); document.getElementById("mb-dm-text").value = ""; }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
}
|
||||
|
||||
function renderModBoardCard(p) {
|
||||
const id = p._id || gv(p, "accountId", "playerId", "AccountId", "PlayerId");
|
||||
const name = gv(p, "displayName", "username", "DisplayName", "Username") || "Unknown";
|
||||
const username = gv(p, "username", "Username") || "?";
|
||||
const img = gv(p, "profileImage", "ProfileImage");
|
||||
const hb = gv(p, "heartbeat") || {};
|
||||
const bd = gv(p, "moderationBlockDetails", "ModerationBlockDetails") || {};
|
||||
const tm = gv(p, "toxMod", "ToxMod") || {};
|
||||
|
||||
document.getElementById("mb-pfp").src = pfpUrl(img);
|
||||
document.getElementById("mb-name").textContent = name;
|
||||
document.getElementById("mb-username").textContent = "@" + username;
|
||||
document.getElementById("mb-id").textContent = "ID: " + id;
|
||||
|
||||
const badges = [];
|
||||
badges.push(hb.isOnline
|
||||
? '<span class="badge badge-online">Online</span>'
|
||||
: '<span class="badge badge-offline">Offline</span>');
|
||||
if (bd.isBan || bd.IsBan) badges.push('<span class="badge badge-banned">Banned</span>');
|
||||
if (bd.isVoiceModAutoban || bd.IsVoiceModAutoban) badges.push('<span class="badge badge-banned">Voice Banned</span>');
|
||||
if (gv(p, "isJunior", "IsJunior")) badges.push('<span class="badge badge-banned">Comms Revoked (Junior)</span>');
|
||||
const strikes = tm.strikes ?? tm.Strikes ?? 0;
|
||||
if (strikes) badges.push(`<span class="badge badge-offline">${strikes} strike${strikes === 1 ? "" : "s"}</span>`);
|
||||
if (gv(p, "discordLinked", "DiscordLinked") || gv(p, "discordUserId", "DiscordUserId"))
|
||||
badges.push(`<span class="badge badge-online">Discord: ${discordLabel(p)}</span>`);
|
||||
document.getElementById("mb-badges").innerHTML = badges.join(" ");
|
||||
|
||||
document.getElementById("mb-player-card").style.display = "block";
|
||||
}
|
||||
|
||||
async function loadAnnouncements() {
|
||||
const el = document.getElementById("ann-list");
|
||||
if (!el) return;
|
||||
const res = await api("/announcements");
|
||||
const list = res.ok && Array.isArray(res.data) ? res.data : [];
|
||||
el.innerHTML = "";
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<div class="muted">No active announcements.</div>';
|
||||
return;
|
||||
}
|
||||
list.forEach(a => {
|
||||
const id = gv(a, "announcementId", "AnnouncementId");
|
||||
const title = gv(a, "title", "Title") || "(no title)";
|
||||
const body = gv(a, "body", "Body") || "";
|
||||
const row = document.createElement("div");
|
||||
row.className = "field-row";
|
||||
row.style.alignItems = "center";
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.style.flex = "1";
|
||||
const t = document.createElement("strong");
|
||||
t.textContent = title;
|
||||
const b = document.createElement("div");
|
||||
b.className = "muted";
|
||||
b.style.fontSize = "12px";
|
||||
b.textContent = body.length > 120 ? body.slice(0, 120) + "…" : body;
|
||||
info.appendChild(t);
|
||||
info.appendChild(b);
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "btn-danger";
|
||||
btn.textContent = "Cancel";
|
||||
btn.addEventListener("click", async () => {
|
||||
const r = await api(`/announcement/delete/${id}`, "POST", {});
|
||||
if (r.ok) { toast("Announcement cancelled", "ok"); await loadAnnouncements(); }
|
||||
else toast("Failed: " + (r.data?.error || r.status), "err");
|
||||
});
|
||||
|
||||
row.appendChild(info);
|
||||
row.appendChild(btn);
|
||||
el.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function setupServerPanel() {
|
||||
document.getElementById("ann-send-btn").addEventListener("click", async () => {
|
||||
const title = document.getElementById("ann-title").value.trim();
|
||||
const body = document.getElementById("ann-body").value.trim();
|
||||
if (!title && !body) { toast("Title or body required", "err"); return; }
|
||||
const res = await api("/announcement", "POST", {
|
||||
AnnouncementType: document.getElementById("ann-type").value,
|
||||
Title: document.getElementById("ann-title").value.trim() || null,
|
||||
Body: document.getElementById("ann-body").value.trim() || null,
|
||||
ImageName: document.getElementById("ann-image").value.trim() || null,
|
||||
LinkType: document.getElementById("ann-linktype").value,
|
||||
LinkName: document.getElementById("ann-linkname").value.trim() || null,
|
||||
LinkUri: document.getElementById("ann-linkuri").value.trim() || null
|
||||
});
|
||||
if (res.ok) { toast("Announcement published", "ok"); await loadAnnouncements(); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
document.getElementById("ann-clear-btn").addEventListener("click", () => {
|
||||
showConfirm("Clear Announcement", "Remove all announcements so players see none?", async () => {
|
||||
const res = await api("/announcement", "POST", { clear: true });
|
||||
if (res.ok) { toast("Announcement cleared", "ok"); await loadAnnouncements(); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
document.getElementById("ann-refresh-btn").addEventListener("click", loadAnnouncements);
|
||||
loadAnnouncements();
|
||||
|
||||
(async () => {
|
||||
const res = await api("/community-banner");
|
||||
if (res.ok) {
|
||||
document.getElementById("cbb-message").value = res.data?.message || "";
|
||||
document.getElementById("cbb-url").value = res.data?.moreInfoUrl || "";
|
||||
}
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
const res = await api("/system-prompt");
|
||||
if (res.ok) {
|
||||
document.getElementById("ccsp-message").value = res.data?.message || "";
|
||||
}
|
||||
})();
|
||||
|
||||
document.getElementById("crash-all-btn").addEventListener("click", () => {
|
||||
showConfirm("Crash Everyone", "Send a crash websocket event to all online non-developer players?", async () => {
|
||||
const res = await api("/players/close-all", "POST", {});
|
||||
if (res.ok) toast(`Closed ${res.data?.playersCrashed ?? "?"} player(s)`, "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("ccsp-send-btn").addEventListener("click", async () => {
|
||||
extendSession();
|
||||
const message = document.getElementById("ccsp-message").value.trim();
|
||||
try {
|
||||
const res = await fetch(BASE + "/system-prompt", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Director-Token": sessionToken || ""
|
||||
},
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) { forceLogout("Your session is no longer valid."); return; }
|
||||
if (res.ok) toast("System prompt updated", "ok");
|
||||
else toast("Failed: " + res.status, "err");
|
||||
} catch (e) {
|
||||
toast("Failed: " + e.message, "err");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("cbb-send-btn").addEventListener("click", async () => {
|
||||
const message = document.getElementById("cbb-message").value.trim();
|
||||
const moreInfoUrl = document.getElementById("cbb-url").value.trim();
|
||||
const res = await api("/community-banner", "POST", { message, moreInfoUrl });
|
||||
if (res.ok) toast("Community board banner updated", "ok");
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
|
||||
document.getElementById("db-maint-btn").addEventListener("click", () => {
|
||||
const mins = parseInt(document.getElementById("db-maint-minutes").value) || 10;
|
||||
showConfirm("Send Maintenance Alert", `Broadcast maintenance alert for ${mins} minutes to all connected clients?`, async () => {
|
||||
const res = await api("/maintenance/schedule", "POST", { minutes: mins });
|
||||
if (res.ok) toast("Maintenance alert sent", "ok");
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("db-clear-rooms-btn").addEventListener("click", () => {
|
||||
showConfirm("Clear Rooms", "Clear all non-dorm rooms and re-import from ImportRooms.json?", async () => {
|
||||
const res = await api("/rooms/clear", "POST", {});
|
||||
if (res.ok) toast("Rooms cleared and re-imported", "ok");
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("db-reset-btn").addEventListener("click", () => {
|
||||
showConfirm("WIPE Database", "This will permanently wipe ALL players, rooms, inventions, friends, and events. This cannot be undone.", async () => {
|
||||
const res = await api("/db/reset", "POST", {});
|
||||
if (res.ok) { toast("Database wiped", "ok"); await loadDbStats(); }
|
||||
else toast("Failed", "err");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("coach-btn").addEventListener("click", async () => {
|
||||
const coachMsgValue = document.getElementById("coach-msg").value;
|
||||
const res = await api("/coach/send-msg", "POST", { message: coachMsgValue });
|
||||
if (res.ok) {
|
||||
toast("Message sent!", "ok");
|
||||
document.getElementById("coach-msg").value = "";
|
||||
await loadDbStats();
|
||||
} else {
|
||||
toast("Failed", "err");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function switchPanel(name) {
|
||||
document.querySelectorAll(".nav-btn").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".panel").forEach(p => p.classList.remove("active"));
|
||||
document.querySelector(`.nav-btn[data-panel="${name}"]`)?.classList.add("active");
|
||||
document.getElementById("panel-" + name)?.classList.add("active");
|
||||
}
|
||||
|
||||
function fmtDate(val) {
|
||||
if (!val) return "-";
|
||||
try { return new Date(val).toLocaleString(); } catch { return val; }
|
||||
}
|
||||
|
||||
function accessibilityName(v) {
|
||||
const m = { 0: "Private", 1: "Public", 2: "Unlisted", 3: "Dev Only", 4: "Dev Unlisted" };
|
||||
return m[v] ?? v ?? "-";
|
||||
}
|
||||
// --- Anti-Cheat ---------------------------------------------------------------
|
||||
// Hardware bans, and the machine link table behind them. The panel is built around
|
||||
// one idea: a machine ID is shared by everyone who has used that computer, so the
|
||||
// linked account list is shown first and the ban button sits underneath it.
|
||||
|
||||
function setupAntiCheatPanel() {
|
||||
document.getElementById("ac-search-btn").addEventListener("click", acSearch);
|
||||
document.getElementById("ac-search-input").addEventListener("keydown", e => {
|
||||
if (e.key === "Enter") acSearch();
|
||||
});
|
||||
document.getElementById("ac-list-banned-btn").addEventListener("click", acListBanned);
|
||||
|
||||
document.getElementById("ac-ban-btn").addEventListener("click", () => {
|
||||
const hwid = document.getElementById("ac-ban-hwid").value.trim();
|
||||
const playerId = parseInt(document.getElementById("ac-ban-player").value) || null;
|
||||
const reason = document.getElementById("ac-ban-reason").value.trim();
|
||||
const daysVal = document.getElementById("ac-ban-days").value.trim();
|
||||
const durationDays = daysVal ? parseInt(daysVal) : null;
|
||||
|
||||
if (!hwid && !playerId) { toast("Give a machine ID or a player ID", "err"); return; }
|
||||
if (!reason) { toast("A reason is required", "err"); return; }
|
||||
|
||||
const what = hwid ? `machine ${acShort(hwid)}` : `every machine player ${playerId} uses`;
|
||||
const term = durationDays ? `${durationDays} day(s)` : "permanently";
|
||||
|
||||
showConfirm("Ban Machine",
|
||||
`Ban ${what} ${term}? Every account linked to it will be banned too.`,
|
||||
() => acBan({ hwid: hwid || null, playerId, reason, durationDays }));
|
||||
});
|
||||
|
||||
document.getElementById("ac-unban-btn").addEventListener("click", () => {
|
||||
const hwid = document.getElementById("ac-unban-hwid").value.trim();
|
||||
if (!hwid) return;
|
||||
|
||||
showConfirm("Lift Hardware Ban",
|
||||
`Lift the ban on machine ${acShort(hwid)}? Accounts banned with it stay banned.`,
|
||||
async () => {
|
||||
const res = await api("/anticheat/machines/unban", "POST", { hwid });
|
||||
if (res.ok) { toast("Hardware ban lifted", "ok"); acListBanned(); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function acBan(body) {
|
||||
const res = await api("/anticheat/machines/ban", "POST", body);
|
||||
|
||||
if (!res.ok) { toast("Failed: " + (res.data?.error || res.status), "err"); return; }
|
||||
|
||||
const machines = res.data?.hwids?.length ?? 0;
|
||||
const accounts = res.data?.bannedAccounts?.length ?? 0;
|
||||
toast(`Banned ${machines} machine(s), ${accounts} account(s)`, "ok");
|
||||
|
||||
acListBanned();
|
||||
}
|
||||
|
||||
async function acSearch() {
|
||||
const raw = document.getElementById("ac-search-input").value.trim();
|
||||
if (!raw) return;
|
||||
|
||||
// A machine ID is a long hash and a player ID is a number, so the box takes either
|
||||
// and works out which one it was given.
|
||||
const query = /^\d+$/.test(raw) ? `playerId=${raw}` : `hwid=${encodeURIComponent(raw)}`;
|
||||
|
||||
const res = await api(`/anticheat/machines?${query}`);
|
||||
if (!res.ok) { toast("Lookup failed: " + (res.data?.error || res.status), "err"); return; }
|
||||
|
||||
acRenderMachines(res.data, "No machine on record for that.");
|
||||
}
|
||||
|
||||
async function acListBanned() {
|
||||
const res = await api("/anticheat/machines/banned");
|
||||
if (!res.ok) { toast("Failed to load banned machines", "err"); return; }
|
||||
|
||||
acRenderMachines(res.data, "No machines are hardware banned.");
|
||||
}
|
||||
|
||||
function acShort(hwid) {
|
||||
return !hwid ? "?" : (hwid.length > 20 ? hwid.slice(0, 10) + "…" + hwid.slice(-6) : hwid);
|
||||
}
|
||||
|
||||
function acDate(v) {
|
||||
if (!v) return "-";
|
||||
const d = new Date(v);
|
||||
return isNaN(d) ? "-" : d.toLocaleString();
|
||||
}
|
||||
|
||||
function acRenderMachines(machines, emptyText) {
|
||||
const el = document.getElementById("ac-results");
|
||||
el.innerHTML = "";
|
||||
|
||||
if (!machines || machines.length === 0) {
|
||||
el.innerHTML = `<div class="empty-state">${emptyText}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "results-list";
|
||||
|
||||
machines.forEach(m => list.appendChild(acMachineCard(m)));
|
||||
el.appendChild(list);
|
||||
}
|
||||
|
||||
function acMachineCard(m) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "ac-machine" + (m.banned ? " ac-machine-banned" : "");
|
||||
|
||||
const players = m.players || [];
|
||||
const bannedCount = players.filter(p => p.banned).length;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "ac-machine-head";
|
||||
head.innerHTML = `
|
||||
<div>
|
||||
<div class="ac-hwid" title="${m.hwid || ""}">${acShort(m.hwid)}</div>
|
||||
<div class="muted">Seen ${acDate(m.firstSeen)} → ${acDate(m.lastSeen)}</div>
|
||||
</div>
|
||||
<div class="ac-machine-badges">
|
||||
${m.banned ? '<span class="badge badge-banned">Banned</span>' : ""}
|
||||
<span class="chip">${players.length} account${players.length === 1 ? "" : "s"}</span>
|
||||
${bannedCount ? `<span class="chip">${bannedCount} already banned</span>` : ""}
|
||||
</div>`;
|
||||
card.appendChild(head);
|
||||
|
||||
if (m.banned) {
|
||||
const ban = document.createElement("div");
|
||||
ban.className = "ac-ban-detail";
|
||||
ban.innerHTML = `
|
||||
<div><strong>Reason:</strong> ${m.banReason || "-"}</div>
|
||||
<div><strong>By:</strong> ${m.bannedBy || "-"} · ${acDate(m.bannedAt)}</div>
|
||||
<div><strong>Expires:</strong> ${m.banExpiresAt ? acDate(m.banExpiresAt) : "never"}</div>`;
|
||||
card.appendChild(ban);
|
||||
}
|
||||
|
||||
const accounts = document.createElement("div");
|
||||
accounts.className = "ac-accounts";
|
||||
|
||||
if (players.length === 0) {
|
||||
accounts.innerHTML = '<div class="muted">No accounts linked yet.</div>';
|
||||
} else {
|
||||
players.forEach(p => {
|
||||
const chip = document.createElement("button");
|
||||
chip.className = "ac-account" + (p.banned ? " ac-account-banned" : "");
|
||||
chip.innerHTML = `
|
||||
<img src="${pfpUrl(p.profileImage)}" onerror="this.src='/imageserver/DefaultPFP.png'">
|
||||
<span>${p.username || "Unknown"}</span>
|
||||
<span class="muted">${p.playerId}</span>`;
|
||||
// Straight through to the player, because the next thing a moderator wants after
|
||||
// seeing a name on a machine is that account.
|
||||
chip.addEventListener("click", () => {
|
||||
document.querySelector('.nav-btn[data-panel="players"]').click();
|
||||
loadPlayerDetail(p.playerId);
|
||||
});
|
||||
accounts.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
card.appendChild(accounts);
|
||||
card.appendChild(acMachineActions(m));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function acMachineActions(m) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "ac-machine-actions";
|
||||
|
||||
const fill = document.createElement("button");
|
||||
fill.className = "btn-ghost";
|
||||
fill.textContent = "Copy ID to form";
|
||||
fill.addEventListener("click", () => {
|
||||
document.getElementById("ac-ban-hwid").value = m.hwid;
|
||||
document.getElementById("ac-unban-hwid").value = m.hwid;
|
||||
toast("Machine ID copied into the ban form");
|
||||
});
|
||||
row.appendChild(fill);
|
||||
|
||||
if (m.banned) {
|
||||
const unban = document.createElement("button");
|
||||
unban.className = "btn-action";
|
||||
unban.textContent = "Lift ban";
|
||||
unban.addEventListener("click", () => {
|
||||
showConfirm("Lift Hardware Ban",
|
||||
`Lift the ban on machine ${acShort(m.hwid)}? Accounts banned with it stay banned.`,
|
||||
async () => {
|
||||
const res = await api("/anticheat/machines/unban", "POST", { hwid: m.hwid });
|
||||
if (res.ok) { toast("Hardware ban lifted", "ok"); acListBanned(); }
|
||||
else toast("Failed: " + (res.data?.error || res.status), "err");
|
||||
});
|
||||
});
|
||||
row.appendChild(unban);
|
||||
} else {
|
||||
const ban = document.createElement("button");
|
||||
ban.className = "btn-danger";
|
||||
ban.textContent = "Ban this machine";
|
||||
ban.addEventListener("click", () => {
|
||||
const reason = document.getElementById("ac-ban-reason").value.trim();
|
||||
if (!reason) {
|
||||
toast("Fill in a reason below first", "err");
|
||||
document.getElementById("ac-ban-reason").focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const daysVal = document.getElementById("ac-ban-days").value.trim();
|
||||
const durationDays = daysVal ? parseInt(daysVal) : null;
|
||||
const count = (m.players || []).length;
|
||||
|
||||
showConfirm("Ban Machine",
|
||||
`Ban machine ${acShort(m.hwid)}? ${count} linked account(s) will be banned with it.`,
|
||||
() => acBan({ hwid: m.hwid, playerId: null, reason, durationDays }));
|
||||
});
|
||||
row.appendChild(ban);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 874 KiB |
@@ -0,0 +1,280 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Link Discord · Vanadium</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><defs><linearGradient id='g' x1='0' y1='0' x2='0' y2='1'><stop offset='0%25' stop-color='%237c5cbf'/><stop offset='100%25' stop-color='%235b8dee'/></linearGradient></defs><text y='.9em' font-size='90' fill='url(%23g)'>⬡</text></svg>">
|
||||
<style>
|
||||
@import url('https://fonts.cdnfonts.com/css/futura-pt');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--surface: rgba(255,238,215,0.88);
|
||||
--surface2: rgba(255,228,200,0.68);
|
||||
--border: rgba(0,0,0,0.12);
|
||||
--accent: #f26522;
|
||||
--accent2: #ff9a00;
|
||||
--text: #163573;
|
||||
--text-muted: #5a5a6a;
|
||||
--danger: #c0392b;
|
||||
--success: #27ae60;
|
||||
--discord: #5865f2;
|
||||
--discord-hover: #4752c4;
|
||||
--font: 'Futura PT', Futura, 'Century Gothic', 'Trebuchet MS', sans-serif;
|
||||
--radius: 8px;
|
||||
--radius-sm: 5px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(rgba(0,0,0,0.45), rgba(0,0,0,0.45)), url('https://i.imgur.com/Z0AXeTL.png') center/cover fixed;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
background: linear-gradient(145deg, #ffeedd 0%, #ffeedd 60%, #d8cfc8 100%);
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 16px;
|
||||
padding: 24px 90px;
|
||||
text-align: center;
|
||||
width: 380px;
|
||||
max-width: 92vw;
|
||||
box-shadow: 0 8px 48px rgba(0,0,0,0.35);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 42px;
|
||||
color: #282d4c;
|
||||
-webkit-text-fill-color: #282d4c;
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.auth-box h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #282d4c;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.auth-box .subtitle {
|
||||
color: #5a5a6a;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-box .subtitle strong {
|
||||
color: #282d4c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background: linear-gradient(160deg, #cc4a28 0%, #f27530 100%);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 13px 20px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.18s, transform 0.12s, box-shadow 0.18s;
|
||||
letter-spacing: 0.04em;
|
||||
box-shadow: 0 2px 14px rgba(176,62,34,0.35);
|
||||
}
|
||||
|
||||
.cta-btn:hover {
|
||||
opacity: 0.88;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 22px rgba(176,62,34,0.45);
|
||||
}
|
||||
|
||||
.cta-btn:active { transform: translateY(0); }
|
||||
|
||||
#code-input {
|
||||
width: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); font-family: 'Consolas', ui-monospace, monospace; font-size: 28px; font-weight: 700;
|
||||
letter-spacing: 0.4em; text-align: center; padding: 16px 12px; margin-bottom: 18px; outline: none;
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
#code-input::placeholder { color: var(--text-muted); letter-spacing: 0.3em; opacity: 0.5; }
|
||||
#code-input:focus { border-color: var(--accent); box-shadow: 0 4px 22px rgba(176,62,34,0.45); }
|
||||
|
||||
.discord-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
color: rgba(255,255,255,0.4);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.divider::before, .divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.requirements {
|
||||
background: rgba(0,0,0,0.18);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.requirements .req-title {
|
||||
color: rgba(255,255,255,0.55);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.requirements ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.requirements ul li {
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.requirements ul li::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#status-msg {
|
||||
margin-top: 18px;
|
||||
min-height: 20px;
|
||||
font-size: 12px;
|
||||
color: #5a5a6a;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#status-msg.err { color: #e74c3c; }
|
||||
#status-msg.ok { color: #4cd98a; }
|
||||
#status-msg.loading { color: var(--accent2); }
|
||||
|
||||
.footer-note {
|
||||
margin-top: 28px;
|
||||
color: rgba(255,255,255,0.3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.loading-dot {
|
||||
display: inline-block;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="auth-box">
|
||||
<span class="logo">⬡</span>
|
||||
<h1>Link Discord</h1>
|
||||
<p class="subtitle">Check your in-game Coach Messages for the<br><strong>6-digit confirmation code</strong>.</p>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="input-wrapper">
|
||||
<input type="text" id="code-input" placeholder="000000" maxlength="6" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<button id="link-btn" class="cta-btn">Confirm Link</button>
|
||||
</div>
|
||||
|
||||
<div id="status-msg"></div>
|
||||
<p class="footer-note">Vanadium Project 2023</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const input = document.getElementById("code-input");
|
||||
const btn = document.getElementById("link-btn");
|
||||
const statusEl = document.getElementById("status-msg");
|
||||
|
||||
function setStatus(msg, cls) { statusEl.className = cls || ""; statusEl.textContent = msg; }
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
input.value = input.value.replace(/[^0-9]/g, "").slice(0, 6);
|
||||
});
|
||||
input.addEventListener("keydown", e => { if (e.key === "Enter") btn.click(); });
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
const pin = input.value.trim();
|
||||
if (pin.length !== 6) { setStatus("Enter the full 6-digit confirm code.", "err"); return; }
|
||||
btn.disabled = true;
|
||||
setStatus("Confirming…", "loading");
|
||||
try {
|
||||
const res = await fetch("/beta/verifydiscord/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pin })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data.success) {
|
||||
setStatus("Successfully linked your Discord! You can close this page.", "ok");
|
||||
input.disabled = true;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
setStatus(data.error || "That PIN is invalid or expired.", "err");
|
||||
}
|
||||
} catch {
|
||||
btn.disabled = false;
|
||||
setStatus("Network error. Please try again.", "err");
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,206 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0eeec 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: linear-gradient(to bottom right, #f97316 0%, #ea580c 100%);
|
||||
padding: 14px 28px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar-logo {
|
||||
color: #fff;
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.5px;
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
}
|
||||
|
||||
.topbar-meta {
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
line-height: 1.6;
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
}
|
||||
|
||||
.topbar-shadow {
|
||||
height: 6px;
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0.12), transparent);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: none;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 32px;
|
||||
}
|
||||
|
||||
.main-content.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
align-items: flex-start;
|
||||
max-width: 860px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.left-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thumbnail-wrap {
|
||||
width: 300px;
|
||||
height: 200px;
|
||||
background: #111;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.thumbnail-wrap img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thumbnail-meta {
|
||||
font-size: 0.82rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.right-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 2.4rem;
|
||||
color: #111;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.room-description {
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
color: #444;
|
||||
line-height: 1.5;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.95rem;
|
||||
color: #222;
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
}
|
||||
|
||||
.stat-row svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: #111;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.join-btn {
|
||||
margin-top: 18px;
|
||||
background: linear-gradient(to bottom right, #f97316, #ea580c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 12px 32px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
transition: opacity 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.join-btn:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: none;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 60px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-state.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.error-state h2 {
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Futura PT', 'Futura', 'Century Gothic', sans-serif;
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.loading-state.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vanadium</title>
|
||||
<link rel="stylesheet" href="/RecNet/Frontend/VanNet.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="topbar-logo">Vanadium</div>
|
||||
<div class="topbar-meta" id="topbar-meta"></div>
|
||||
</div>
|
||||
<div class="topbar-shadow"></div>
|
||||
|
||||
<div class="loading-state" id="loading-state">Loading...</div>
|
||||
|
||||
<div class="main-content" id="main-content">
|
||||
<div class="card">
|
||||
<div class="left-col">
|
||||
<div class="thumbnail-wrap">
|
||||
<img id="room-thumbnail" src="" alt="Room Thumbnail">
|
||||
</div>
|
||||
<div class="thumbnail-meta" id="thumbnail-meta"></div>
|
||||
</div>
|
||||
<div class="right-col">
|
||||
<div class="room-name" id="room-name"></div>
|
||||
<div class="room-description" id="room-description"></div>
|
||||
<div class="stats">
|
||||
<div class="stat-row">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"></path>
|
||||
</svg>
|
||||
<span id="cheer-count">0 cheers</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
|
||||
<rect x="6" y="10" width="5" height="10" style="display:none"/>
|
||||
<path d="M8 20v-8H5.5c-.28 0-.5-.22-.5-.5V10c0-.28.22-.5.5-.5H8V7c0-2.21 1.79-4 4-4s4 1.79 4 4v2.5h2.5c.28 0 .5.22.5.5v1.5c0 .28-.22.5-.5.5H16V20h-3v-6h-2v6H8z" style="display:none"/>
|
||||
<path d="M9 20v-9H6l6-6 6 6h-3v9H9z" style="display:none"/>
|
||||
</svg>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h7l-2-2H5V5h14v5l2 2V5c0-1.1-.9-2-2-2zm-5 10H8v-2h6v2zm3-4H8V7h9v2z" style="display:none"/>
|
||||
<path d="M18 10.5V3H6v18h5v-1H7V4h10v7l1-.5zM16 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 1.5c.28 0 .5.22.5.5v1.5H18c.28 0 .5.22.5.5s-.22.5-.5.5h-1.5V20c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-1.5H14c-.28 0-.5-.22-.5-.5s.22-.5.5-.5h1.5V16c0-.28.22-.5.5-.5z" style="display:none"/>
|
||||
</svg>
|
||||
<svg class="door-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 19V5c0-1.1-.9-2-2-2H7c-1.1 0-2 .9-2 2v14H4v2h16v-2h-1zm-8-6v-2h2v2h-2zm0 0"/>
|
||||
<path d="M5 19V5c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2v14M3 21h18M9 10h1" style="display:none"/>
|
||||
</svg>
|
||||
<span id="visitor-count">0 visitors</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path>
|
||||
</svg>
|
||||
<span id="favorite-count">0 favorites</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="join-btn" id="join-btn">Join Room</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-state" id="error-state">
|
||||
<h2>Link Unavailable</h2>
|
||||
<p>This action link is invalid or has expired.</p>
|
||||
</div>
|
||||
|
||||
<script src="/RecNet/Frontend/VanNet.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,126 @@
|
||||
const IMAGE_SERVER = "https://reloxa.xyz/imageserver/";
|
||||
|
||||
function getParam(name) {
|
||||
return new URLSearchParams(window.location.search).get(name);
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function truncate(str, max) {
|
||||
if (!str) return "";
|
||||
return str.length > max ? str.slice(0, max) + "..." : str;
|
||||
}
|
||||
|
||||
async function loadActionLink(code) {
|
||||
const res = await fetch(`/actionlink/${code}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function loadRoom(roomId) {
|
||||
const res = await fetch(`/rooms/${roomId}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function loadPlayer(playerId) {
|
||||
const res = await fetch(`/api/playerprofile/bulk-account-by-id?id=${playerId}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) return data[0] || null;
|
||||
return data;
|
||||
}
|
||||
|
||||
function showError() {
|
||||
document.getElementById("loading-state").classList.add("hidden");
|
||||
document.getElementById("error-state").classList.add("visible");
|
||||
}
|
||||
|
||||
function showMain() {
|
||||
document.getElementById("loading-state").classList.add("hidden");
|
||||
document.getElementById("main-content").classList.add("visible");
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const code = getParam("a");
|
||||
if (!code) { showError(); return; }
|
||||
|
||||
let linkData;
|
||||
try { linkData = await loadActionLink(code); } catch { showError(); return; }
|
||||
if (!linkData || !linkData.isValid) { showError(); return; }
|
||||
|
||||
let parsedData = {};
|
||||
try { parsedData = JSON.parse(linkData.data); } catch {}
|
||||
|
||||
const codeType = parsedData.codeType ?? linkData.codeType ?? 0;
|
||||
if (codeType !== 5) { showError(); return; }
|
||||
|
||||
const roomId = parsedData.roomId ?? parsedData.extraDataId ?? linkData.extraDataId;
|
||||
if (!roomId) { showError(); return; }
|
||||
|
||||
let room;
|
||||
try { room = await loadRoom(roomId); } catch { showError(); return; }
|
||||
if (!room) { showError(); return; }
|
||||
|
||||
let creator;
|
||||
try { creator = await loadPlayer(room.creatorAccountId ?? room.CreatorAccountId); } catch {}
|
||||
|
||||
const roomName = room.name ?? room.Name ?? "Unknown Room";
|
||||
const description = room.description ?? room.Description ?? "";
|
||||
const imageName = room.imageName ?? room.ImageName ?? "";
|
||||
const stats = room.stats ?? room.Stats ?? {};
|
||||
const cheerCount = stats.cheerCount ?? stats.CheerCount ?? 0;
|
||||
const visitCount = stats.visitCount ?? stats.VisitCount ?? 0;
|
||||
const favoriteCount = stats.favoriteCount ?? stats.FavoriteCount ?? 0;
|
||||
const createdAt = room.createdAt ?? room.CreatedAt ?? "";
|
||||
const creatorName = creator ? (creator.displayName ?? creator.username ?? "") : "";
|
||||
const creatorUsername = creator ? (creator.username ?? "") : "";
|
||||
const expiresAt = parsedData.expiresAt ?? linkData.expiresAt ?? "";
|
||||
|
||||
document.getElementById("room-thumbnail").src = imageName ? `${IMAGE_SERVER}${imageName}` : "";
|
||||
document.getElementById("room-name").textContent = `^${roomName}`;
|
||||
document.getElementById("room-description").textContent = truncate(description, 200);
|
||||
document.getElementById("cheer-count").textContent = `${cheerCount.toLocaleString()} cheers`;
|
||||
document.getElementById("visitor-count").textContent = `${visitCount.toLocaleString()} visitors`;
|
||||
document.getElementById("favorite-count").textContent = `${favoriteCount.toLocaleString()} favorites`;
|
||||
|
||||
const metaParts = [];
|
||||
if (creatorUsername) metaParts.push(`@${creatorUsername}`);
|
||||
if (expiresAt) metaParts.push(`Expires ${formatDate(expiresAt)}`);
|
||||
document.getElementById("topbar-meta").innerHTML = metaParts.join("<br>");
|
||||
|
||||
const thumbMeta = [];
|
||||
if (createdAt) thumbMeta.push(`(${formatDate(createdAt)})`);
|
||||
if (creatorName) thumbMeta.push(`@${creatorName}`);
|
||||
document.getElementById("thumbnail-meta").textContent = thumbMeta.join(" ");
|
||||
|
||||
document.getElementById("join-btn").addEventListener("click", () => {
|
||||
window.location.href = `recroom://actionlink/${code}`;
|
||||
});
|
||||
|
||||
showMain();
|
||||
}
|
||||
|
||||
class ActionLinkWebsiteController {
|
||||
constructor() {
|
||||
this.code = getParam("a");
|
||||
}
|
||||
|
||||
reload() {
|
||||
document.getElementById("main-content").classList.remove("visible");
|
||||
document.getElementById("error-state").classList.remove("visible");
|
||||
document.getElementById("loading-state").classList.remove("hidden");
|
||||
init();
|
||||
}
|
||||
|
||||
getCode() {
|
||||
return this.code;
|
||||
}
|
||||
}
|
||||
|
||||
window.ActionLinkWebsiteController = new ActionLinkWebsiteController();
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user