const API = '/recnet/api';
const app = document.querySelector('#app');
const sidenav = document.querySelector('#sidenav');
const navBackdrop = document.querySelector('#navBackdrop');
const navToggle = document.querySelector('#navToggle');
const topbarUser = document.querySelector('#topbarUser');
const envBadge = document.querySelector('#envBadge');
let currentUser = null;
const esc = s => String(s ?? '').replace(/[&<>'"]/g, c => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[c]));
function fetchJson(url, init = {}) {
return fetch(url, { ...init, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(init.headers || {}) } });
}
async function get(url) {
const r = await fetchJson(url);
if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || `Request failed (${r.status})`); }
return r.json();
}
async function send(url, method, body) {
const r = await fetchJson(url, { method, body: body !== undefined ? JSON.stringify(body) : undefined });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || `Request failed (${r.status})`);
return data;
}
function openNav() { sidenav.classList.add('open'); navBackdrop.classList.add('open'); }
function closeNav() { sidenav.classList.remove('open'); navBackdrop.classList.remove('open'); }
navToggle.addEventListener('click', () => sidenav.classList.contains('open') ? closeNav() : openNav());
navBackdrop.addEventListener('click', closeNav);
function applyRoleGating() {
const isDev = currentUser?.isDeveloper === true;
document.querySelectorAll('[data-requires="developer"]').forEach(el => {
el.style.display = isDev ? '' : 'none';
});
document.querySelectorAll('.dev-only').forEach(el => {
el.style.display = isDev ? 'flex' : 'none';
});
document.querySelectorAll('.dev-block').forEach(el => {
el.style.display = isDev ? 'block' : 'none';
});
topbarUser.textContent = currentUser ? `${currentUser.displayName || currentUser.username} · ${currentUser.isDeveloper ? 'Developer' : currentUser.isModerator ? 'Moderator' : 'Staff'}` : '';
envBadge.textContent = currentUser?.isDeveloper ? 'DEV ACCESS' : 'PROD';
envBadge.className = 'env-badge' + (currentUser?.isDeveloper ? ' dev' : '');
}
function renderLoginGate() {
app.innerHTML = `
Staff sign-in required Log in with a Moderator or Developer account through the main site, then come back here.
Go to main site `;
}
function setActiveNav(view) {
document.querySelectorAll('.nav-link').forEach(link => link.classList.toggle('active', link.dataset.view === view));
}
const DEVELOPER_ONLY_VIEWS = new Set([
'community-board', 'coach-gifting', 'announcements', 'player-events',
'room-importer', 'logs', 'scanner-logs', 'anticheat-logs', 'config', 'account-creation',
'avatar-items', 'ip-bans', 'steam-blacklist', 'shop'
]);
function renderDeveloperOnlyPlaceholder(view) {
const title = document.querySelector(`.nav-link[data-view="${view}"]`)?.textContent || 'This section';
app.innerHTML = `${esc(title)} needs Developer access This section's actions all require the Developer role on the server, not just Moderator.
`;
}
async function route() {
closeNav();
const hash = location.hash.slice(1);
const [view, ...rest] = hash.split('/');
setActiveNav(view || 'players');
if (DEVELOPER_ONLY_VIEWS.has(view) && currentUser?.isDeveloper !== true) {
renderDeveloperOnlyPlaceholder(view);
return;
}
try {
if (view === 'players' && rest[0]) await playerDetail(rest[0]);
else if (view === 'players' || view === '') await playersList();
else if (view === 'player-reports') await playerReportsView();
else if (view === 'bug-reports') await bugReportsView();
else if (view === 'rooms' && rest[0]) await roomDetail(rest[0]);
else if (view === 'rooms') await roomsList();
else if (view === 'instances') await liveInstances();
else if (view === 'age-verification') await ageVerificationView();
else if (view === 'announcements') await announcementsView();
else if (view === 'player-events') await playerEventsView();
else if (view === 'config') await configView();
else if (view === 'account-creation') await accountCreationView();
else if (view === 'avatar-items') await avatarItemsView();
else if (view === 'room-importer') await roomImporterView();
else if (view === 'logs') await logsView();
else if (view === 'scanner-logs') await scannerLogsView();
else if (view === 'anticheat-logs') await anticheatLogsView();
else if (view === 'ip-bans') await ipBansView();
else if (view === 'community-board') await communityBoardView();
else if (view === 'clubs' && rest[0]) await clubDetail(rest[0]);
else if (view === 'clubs') await clubsView();
else if (view === 'coach-gifting') await coachGiftingView();
else if (view === 'steam-blacklist') await steamBlacklistView();
else if (view === 'shop') await shopView();
else if (view === 'preferences') await preferencesView();
else await placeholder(view);
} catch (error) {
app.innerHTML = ``;
}
}
addEventListener('hashchange', route);
function placeholder(view) {
const title = document.querySelector(`.nav-link[data-view="${view}"]`)?.textContent.replace(' · soon', '') || 'This section';
app.innerHTML = `${esc(title)} isn't built here yet This part of the new panel doesn't exist yet - it still works fine in the original admin panel for now.
`;
}
async function playersList() {
app.innerHTML = `Players Search
`;
(async () => {
const overview = document.querySelector('#playerOverview');
try {
const stats = await get(`${API}/admin/overview`);
const entries = [
['Accounts', stats.accounts], ['Admins', stats.admins], ['Mod Locks', stats.moderationLocks],
['Verified', stats.verified], ['RR+', stats.rrPlus], ['Rooms', stats.rooms],
['Photos', stats.photos], ['Cheers', stats.cheers], ['Comments', stats.comments]
];
overview.innerHTML = entries.map(([label, value]) => `${value} ${esc(label)}
`).join('');
} catch (error) {
overview.innerHTML = `${esc(error.message)}
`;
}
})();
const input = document.querySelector('#playerSearch'), results = document.querySelector('#playerListResults');
async function runSearch() {
results.innerHTML = ' Loading players
';
try {
const players = await get(`${API}/admin/accounts?search=${encodeURIComponent(input.value.trim())}`);
if (!players.length) { results.innerHTML = 'No players found.
'; return; }
results.innerHTML = players.map(p => ` `).join('');
} catch (error) {
results.innerHTML = `${esc(error.message)}
`;
}
}
const playerSearchGo = document.querySelector('#playerSearchGo');
if (playerSearchGo) playerSearchGo.addEventListener('click', runSearch);
if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') runSearch(); });
await runSearch();
}
const ROLE_BADGE_CLASS = { Developer: 'dev', Moderator: 'mod' };
async function playerDetail(accountIdRaw) {
const accountId = Number(accountIdRaw);
app.innerHTML = ' Loading player
';
let player;
try { player = await get(`${API}/admin/accounts/${accountId}`); }
catch (error) { app.innerHTML = `← Back to Players `; return; }
const joined = player.createdAt ? new Date(player.createdAt).toLocaleDateString() : 'Unknown';
const badges = [...player.roles.map(r => `${esc(r)} `)];
if (player.isJunior) badges.push('Junior ');
if (player.ban) badges.push('Banned ');
app.innerHTML = `
← Back to Players
Settings
Relationships
Threads
Inventory
My Photos
Photos of Me
Gifts
Reputation
Progression
Troll
`;
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab));
renderPlayerTab(tab.dataset.tab, accountId);
}));
renderPlayerTab('settings', accountId);
applyRoleGating();
const msgBtn = document.querySelector('[data-act="message"]');
if (msgBtn) msgBtn.addEventListener('click', async () => {
const message = prompt('Message to send as Coach:');
if (!message) return;
try { await send(`${API}/admin/accounts/${accountId}/message`, 'POST', { message }); alert('Sent.'); }
catch (error) { alert(error.message); }
});
const forceJoinBtn = document.querySelector('[data-act="force-join"]');
if (forceJoinBtn) forceJoinBtn.addEventListener('click', async () => {
try { const r = await send(`${API}/admin/accounts/${accountId}/force-join-instance`, 'POST', {}); alert(r.delivered ? `Pushed live into "${r.roomName}".` : `Their record now points at "${r.roomName}" - it'll surface next time they connect.`); }
catch (error) { alert(error.message); }
});
const resetUserBtn = document.querySelector('[data-act="reset-username"]');
if (resetUserBtn) resetUserBtn.addEventListener('click', async () => {
if (!confirm('Reset this account\'s username to a randomly generated one?')) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/username/reset`, 'POST', {});
alert(`Username reset from "${r.previousUsername}" to "${r.username}".`);
await playerDetail(accountIdRaw);
} catch (error) { alert(error.message); }
});
const editProfileBtn = document.querySelector('[data-act="edit-profile"]');
if (editProfileBtn) editProfileBtn.addEventListener('click', async () => {
const newUsername = prompt('New username (3-20 letters, numbers, underscores):', player.username);
if (!newUsername) return;
const newDisplayName = prompt('New display name (1-32 characters):', player.displayName || '');
if (newDisplayName === null) return;
const bio = prompt('Bio (up to 500 characters):', player.bio || '');
if (bio === null) return;
const email = prompt('Email:', player.email || '');
if (email === null) return;
try {
await send(`${API}/admin/accounts/${accountId}/profile`, 'PUT', { username: newUsername, displayName: newDisplayName, bio, email });
alert('Profile updated successfully.');
await playerDetail(accountIdRaw);
} catch (error) { alert(error.message); }
});
const resetPfpBtn = document.querySelector('[data-act="reset-pfp"]');
if (resetPfpBtn) resetPfpBtn.addEventListener('click', async () => {
if (!confirm('Reset this account\'s profile picture to the default?')) return;
try { await send(`${API}/admin/accounts/${accountId}/images/pfp/reset`, 'POST', {}); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const resetBannerBtn = document.querySelector('[data-act="reset-banner"]');
if (resetBannerBtn) resetBannerBtn.addEventListener('click', async () => {
if (!confirm('Reset this account\'s banner image?')) return;
try { await send(`${API}/admin/accounts/${accountId}/images/banner/reset`, 'POST', {}); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const resetPasswordBtn = document.querySelector('[data-act="reset-password"]');
if (resetPasswordBtn) resetPasswordBtn.addEventListener('click', async () => {
const newPassword = prompt('New password (minimum 8 characters):');
if (!newPassword) return;
try { await send(`${API}/admin/accounts/${accountId}/password`, 'PUT', { newPassword }); alert('Password reset.'); }
catch (error) { alert(error.message); }
});
const copyIdBtn = document.querySelector('[data-act="copy-id"]');
if (copyIdBtn) copyIdBtn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(String(accountId)); copyIdBtn.textContent = 'Copied!'; setTimeout(() => copyIdBtn.textContent = 'Copy ID', 1200); }
catch (error) { alert('Could not copy to clipboard.'); }
});
const modLockBtn = document.querySelector('[data-act="mod-lock"]');
if (modLockBtn) modLockBtn.addEventListener('click', async () => {
if (player.moderationLock) {
const removeLinked = player.moderationLock.isRelated ? confirm('Also remove the Moderation Lock from linked accounts?') : false;
if (!confirm('Remove this account\'s Moderation Lock?')) return;
try { await send(`${API}/admin/accounts/${accountId}/moderation-lock`, 'DELETE', { removeLinkedAccounts: removeLinked }); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
return;
}
if (!confirm('Apply a permanent Moderation Lock to this account?')) return;
try { await send(`${API}/admin/accounts/${accountId}/moderation-lock`, 'POST', {}); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const deleteAccountBtn = document.querySelector('[data-act="delete-account"]');
if (deleteAccountBtn) deleteAccountBtn.addEventListener('click', async () => {
const confirmation = prompt(`This permanently deletes the account. Type DELETE ${accountId} to confirm:`);
if (!confirmation) return;
try {
await send(`${API}/admin/accounts/${accountId}`, 'DELETE', { confirmation });
alert('Account deleted.');
location.hash = '#players';
} catch (error) { alert(error.message); }
});
const addRoleBtn = document.querySelector('[data-act="add-role"]');
if (addRoleBtn) addRoleBtn.addEventListener('click', async () => {
const roleSelect = document.querySelector('#roleSelect');
const role = roleSelect ? roleSelect.value : prompt('Role to add (e.g. Moderator, Developer, RRPlus):');
if (!role) return;
try { await send(`${API}/admin/accounts/${accountId}/roles`, 'POST', { role, enabled: true }); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const clearRolesBtn = document.querySelector('[data-act="clear-roles"]');
if (clearRolesBtn) clearRolesBtn.addEventListener('click', async () => {
if (!confirm('Clear all roles from this account?')) return;
try { await send(`${API}/admin/accounts/${accountId}/roles`, 'DELETE'); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
});
const forceIntoArbitraryBtn = document.querySelector('[data-act="force-into-arbitrary"]');
if (forceIntoArbitraryBtn) forceIntoArbitraryBtn.addEventListener('click', async () => {
const targetAccountId = prompt('Account ID of the player whose live instance to force this player into:');
if (!targetAccountId) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/force-into/${Number(targetAccountId)}`, 'POST', {});
alert(r.delivered ? `Forced into "${r.roomName}" live.` : `Record updated to "${r.roomName}" - will apply on next connection.`);
} catch (error) { alert(error.message); }
});
const banBtn = document.querySelector('[data-act="ban"]');
if (banBtn) banBtn.addEventListener('click', async () => {
if (player.ban) {
if (!confirm('Unban this account?')) return;
try { await send(`${API}/admin/accounts/${accountId}/ban`, 'DELETE'); await playerDetail(accountIdRaw); }
catch (error) { alert(error.message); }
return;
}
const reason = prompt('Ban reason:');
if (!reason) return;
const durationAmount = Number(prompt('Duration amount (ignored if permanent):', '1') || '1');
const durationUnit = prompt('Duration unit (seconds/minutes/hours/days/weeks/permanent):', 'days') || 'days';
try {
await send(`${API}/admin/accounts/${accountId}/ban`, 'POST', { reason, linkBan: false, durationAmount, durationUnit });
await playerDetail(accountIdRaw);
} catch (error) { alert(error.message); }
});
}
async function renderPlayerTab(tab, accountId) {
const body = document.querySelector('#tabBody');
if (tab === 'settings') {
body.innerHTML = `Settings
Custom Settings
Add / set
`;
(async () => {
const balanceCurrent = document.querySelector('#balanceCurrent');
try {
const account = await get(`${API}/admin/accounts/${accountId}`);
balanceCurrent.textContent = `Current balance: ${account.balance} tokens.`;
renderPlatforms(account.platforms);
} catch (error) {
balanceCurrent.textContent = error.message;
balanceCurrent.classList.add('error');
}
})();
function renderPlatforms(platforms) {
const list = document.querySelector('#platformList');
if (!platforms || !platforms.length) { list.innerHTML = 'No linked platforms.
'; return; }
list.innerHTML = platforms.map(p => `${esc(p.platform)} ${esc(p.platformId)}
Unlink `).join('');
list.querySelectorAll('.unlink-platform').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm(`Unlink ${btn.dataset.platform} identity ${btn.dataset.platformId}?`)) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/platforms`, 'POST', { platform: btn.dataset.platform, platformId: btn.dataset.platformId, enabled: false });
renderPlatforms(r.platforms);
} catch (error) { alert(error.message); }
}));
}
const applyBalanceBtn = document.querySelector('#applyBalance');
if (applyBalanceBtn) applyBalanceBtn.addEventListener('click', async () => {
const status = document.querySelector('#balanceStatus');
const amount = Number(document.querySelector('#balanceAmount').value);
const add = document.querySelector('#balanceMode').value === 'add';
if (amount < 0) { status.textContent = 'Amount cannot be negative.'; status.classList.add('error'); return; }
try {
const r = await send(`${API}/admin/accounts/${accountId}/balance`, 'POST', { amount, add });
status.textContent = `Balance is now ${r.balance} tokens.`;
status.classList.remove('error');
document.querySelector('#balanceCurrent').textContent = `Current balance: ${r.balance} tokens.`;
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
const addPlatformBtn = document.querySelector('#addPlatform');
if (addPlatformBtn) addPlatformBtn.addEventListener('click', async () => {
const status = document.querySelector('#platformStatus');
const platform = document.querySelector('#newPlatform').value;
const platformId = document.querySelector('#newPlatformId').value.trim();
if (!platformId) { status.textContent = 'Enter a platform ID.'; status.classList.add('error'); return; }
try {
const r = await send(`${API}/admin/accounts/${accountId}/platforms`, 'POST', { platform, platformId, enabled: true });
status.textContent = 'Linked.';
status.classList.remove('error');
renderPlatforms(r.platforms);
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
async function loadSettings() {
const table = document.querySelector('#settingsTable');
try {
const settings = await get(`${API}/admin/accounts/${accountId}/settings`);
if (!settings.length) { table.innerHTML = 'No settings stored.
'; return; }
table.innerHTML = ``;
table.querySelectorAll('.save-setting').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('tr'), key = row.dataset.key;
const settingValue = row.querySelector('.setting-value');
if (!settingValue) return;
const value = settingValue.value;
try { await send(`${API}/admin/accounts/${accountId}/settings`, 'PUT', { key, value }); }
catch (error) { alert(error.message); }
}));
table.querySelectorAll('.delete-setting').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('tr'), key = row.dataset.key;
if (!confirm(`Delete "${key}"?`)) return;
try { await send(`${API}/admin/accounts/${accountId}/settings/${encodeURIComponent(key)}`, 'DELETE'); row.remove(); }
catch (error) { alert(error.message); }
}));
} catch (error) {
table.innerHTML = `${esc(error.message)}
`;
}
}
const addSettingBtn = document.querySelector('#addSetting');
if (addSettingBtn) addSettingBtn.addEventListener('click', async () => {
const newSettingKey = document.querySelector('#newSettingKey');
const newSettingValue = document.querySelector('#newSettingValue');
if (!newSettingKey || !newSettingValue) return;
const key = newSettingKey.value.trim(), value = newSettingValue.value;
if (!key) return;
try {
await send(`${API}/admin/accounts/${accountId}/settings`, 'PUT', { key, value });
newSettingKey.value = '';
newSettingValue.value = '';
await loadSettings();
} catch (error) { alert(error.message); }
});
await loadSettings();
} else if (tab === 'progression') {
body.innerHTML = `Progression & Account State
`;
let account;
try {
account = await get(`${API}/admin/accounts/${accountId}`);
document.querySelector('#progLevel').value = account.level;
document.querySelector('#progXP').value = account.xp;
document.querySelector('#stateUsernameChanges').value = account.availableUsernameChanges ?? 0;
document.querySelector('#statePronouns').value = account.personalPronouns ?? 0;
document.querySelector('#stateEmoji').value = account.displayEmoji || '';
document.querySelector('#stateProfileImage').value = account.profileImagePath || '';
document.querySelector('#stateBannerImage').value = account.bannerImagePath || '';
document.querySelector('#stateIsJunior').checked = !!account.isJunior;
} catch (error) {
body.innerHTML = `${esc(error.message)}
`;
return;
}
function detailsPayload(overrides) {
return {
level: account.level,
xp: account.xp,
isJunior: document.querySelector('#stateIsJunior').checked,
availableUsernameChanges: parseInt(document.querySelector('#stateUsernameChanges').value) || 0,
displayEmoji: document.querySelector('#stateEmoji').value,
personalPronouns: parseInt(document.querySelector('#statePronouns').value) || 0,
profileImage: document.querySelector('#stateProfileImage').value,
bannerImage: document.querySelector('#stateBannerImage').value,
...overrides
};
}
const updateProgBtn = document.querySelector('#updateProgression');
if (updateProgBtn) updateProgBtn.addEventListener('click', async () => {
const status = document.querySelector('#progStatus');
const level = parseInt(document.querySelector('#progLevel').value);
const xp = parseInt(document.querySelector('#progXP').value);
if (level < 1 || level > 50) { status.textContent = 'Level must be 1-50.'; status.classList.add('error'); return; }
if (xp < 0) { status.textContent = 'XP cannot be negative.'; status.classList.add('error'); return; }
try {
await send(`${API}/admin/accounts/${accountId}/details`, 'PUT', detailsPayload({ level, xp }));
account.level = level; account.xp = xp;
status.textContent = 'Updated.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
const updateStateBtn = document.querySelector('#updateAccountState');
if (updateStateBtn) updateStateBtn.addEventListener('click', async () => {
const status = document.querySelector('#stateStatus');
try {
await send(`${API}/admin/accounts/${accountId}/details`, 'PUT', detailsPayload({}));
status.textContent = 'Saved.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
} else if (tab === 'relationships') {
body.innerHTML = `Relationships
`;
applyRoleGating();
async function loadRelationships() {
const list = document.querySelector('#relationshipsList');
list.innerHTML = ' Loading relationships
';
try {
const relationships = await get(`${API}/admin/accounts/${accountId}/relationships`);
if (!relationships.length) { list.innerHTML = 'No relationships found.
'; return; }
const isDev = currentUser?.isDeveloper === true;
list.innerHTML = `Player ID Username Display Name Type Flags ${isDev ? 'Actions ' : ''}${relationships.map(r => `
${r.playerId} ${esc(r.username || 'N/A')} ${esc(r.displayName || 'N/A')} ${esc(r.relationshipType)} ${r.favorited ? '⭐ ' : ''}${r.muted ? '🔇 ' : ''}${r.ignored ? '🚫' : ''} ${isDev ? `Unadd ` : ''}
`).join('')}
`;
list.querySelectorAll('.remove-relationship').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Remove this relationship?')) return;
try {
await send(`${API}/admin/accounts/${accountId}/relationships/friend/${btn.dataset.id}`, 'DELETE');
await loadRelationships();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
const forceAddFriendBtn = document.querySelector('#forceAddFriend');
if (forceAddFriendBtn) forceAddFriendBtn.addEventListener('click', async () => {
const status = document.querySelector('#forceAddFriendStatus');
const targetAccountId = Number(document.querySelector('#forceAddFriendId').value);
if (!targetAccountId) return;
try {
await send(`${API}/admin/accounts/${accountId}/relationships/friend`, 'POST', { targetAccountId });
status.textContent = 'Added.';
status.classList.remove('error');
await loadRelationships();
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
await loadRelationships();
} else if (tab === 'threads') {
body.innerHTML = `Threads
`;
const list = document.querySelector('#threadsList');
const messagesBox = document.querySelector('#threadMessages');
async function openThread(threadId, name) {
messagesBox.innerHTML = `${esc(name)} Loading messages
`;
try {
const messages = await get(`${API}/admin/accounts/${accountId}/chat/threads/${threadId}/messages`);
messagesBox.innerHTML = `${esc(name)} ${messages.length ? messages.map(m => `
${esc(m.senderDisplayName)} ${esc(m.body)}${new Date(m.createdAt).toLocaleString()}
`).join('') : '
No messages.
'}
`;
} catch (error) {
messagesBox.innerHTML = ``;
}
}
try {
const threads = await get(`${API}/admin/accounts/${accountId}/chat/threads`);
if (!threads.length) { list.innerHTML = 'No message threads.
'; return; }
list.innerHTML = threads.map(t => {
const name = t.name || t.members.map(m => m.displayName).join(', ') || `Thread ${t.threadId}`;
return `${esc(name)} ${t.lastMessage ? esc(t.lastMessage.body) : 'No messages yet'}
`;
}).join('');
list.querySelectorAll('.room-row').forEach(row => row.addEventListener('click', () => openThread(row.dataset.id, row.dataset.name)));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
} else if (tab === 'inventory') {
body.innerHTML = `Inventory Search
`;
const searchInput = document.querySelector('#inventorySearch');
const results = document.querySelector('#inventoryResults');
async function loadInventory() {
const search = searchInput.value.trim();
results.innerHTML = ' Loading inventory
';
try {
const data = await get(`${API}/admin/accounts/${accountId}/inventory?search=${encodeURIComponent(search)}`);
const sections = [];
if (data.avatarItems && data.avatarItems.length) {
sections.push(`Avatar Items ${data.avatarItems.map(item => `
${esc(item.friendlyName)}
ID: ${item.avatarItemId}
Owned
`).join('')}
`);
}
if (data.consumables && data.consumables.length) {
sections.push(`Consumables ${data.consumables.map(item => `
${esc(item.friendlyName)}
ID: ${item.consumableItemId}
Quantity:
`).join('')}
`);
}
if (!sections.length) { results.innerHTML = 'No items found.
'; return; }
results.innerHTML = sections.join(' ');
results.querySelectorAll('.inventory-check').forEach(chk => chk.addEventListener('change', async () => {
const item = chk.closest('.inventory-item');
try {
await send(`${API}/admin/accounts/${accountId}/inventory/avatar`, 'POST', {
avatarItemId: Number(item.dataset.id),
avatarItemDesc: item.dataset.desc,
owned: chk.checked
});
} catch (error) { alert(error.message); chk.checked = !chk.checked; }
}));
results.querySelectorAll('.inventory-quantity').forEach(input => input.addEventListener('change', async () => {
const item = input.closest('.inventory-item');
const qty = Number(input.value);
if (qty < 0 || qty > 100000) { alert('Quantity must be 0-100000'); input.value = item.dataset.prevQty || 0; return; }
try {
await send(`${API}/admin/accounts/${accountId}/inventory/consumable`, 'POST', {
consumableItemId: Number(item.dataset.id),
consumableItemDesc: item.dataset.desc,
quantity: qty
});
item.dataset.prevQty = qty;
} catch (error) { alert(error.message); input.value = item.dataset.prevQty || 0; }
}));
} catch (error) {
results.innerHTML = `${esc(error.message)}
`;
}
}
const searchInvBtn = document.querySelector('#searchInventory');
if (searchInvBtn) searchInvBtn.addEventListener('click', loadInventory);
if (searchInput) searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') loadInventory(); });
await loadInventory();
} else if (tab === 'reputation') {
body.innerHTML = `Reputation / Cheer Badges `;
const content = document.querySelector('#reputationContent');
async function loadReputation() {
content.innerHTML = ' Loading reputation
';
try {
const data = await get(`${API}/admin/accounts/${accountId}/reputation`);
content.innerHTML = `
Is Cheerful
Selected Badge
${data.availableBadges.map(b => `${b} `).join('')}
Cheer Counts
General:
Helpful:
Sportsmanship:
Great Host:
Creative:
Save Reputation
`;
const saveRepBtn = document.querySelector('#saveReputation');
if (saveRepBtn) saveRepBtn.addEventListener('click', async () => {
try {
const status = document.querySelector('#reputationStatus');
const isCheerful = document.querySelector('#isCheerful');
const selectedCheer = document.querySelector('#selectedCheer');
const cheerGeneral = document.querySelector('#cheerGeneral');
const cheerHelpful = document.querySelector('#cheerHelpful');
const cheerSportsman = document.querySelector('#cheerSportsman');
const cheerGreatHost = document.querySelector('#cheerGreatHost');
const cheerCreative = document.querySelector('#cheerCreative');
if (!status || !isCheerful || !selectedCheer || !cheerGeneral || !cheerHelpful || !cheerSportsman || !cheerGreatHost || !cheerCreative) return;
status.textContent = 'Saving...';
status.classList.remove('error');
await send(`${API}/admin/accounts/${accountId}/reputation`, 'PUT', {
isCheerful: isCheerful.checked,
selectedCheer: selectedCheer.value,
cheerGeneral: Number(cheerGeneral.value),
cheerHelpful: Number(cheerHelpful.value),
cheerSportsman: Number(cheerSportsman.value),
cheerGreatHost: Number(cheerGreatHost.value),
cheerCreative: Number(cheerCreative.value)
});
status.textContent = 'Saved.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
} catch (error) {
content.innerHTML = `${esc(error.message)}
`;
}
}
await loadReputation();
} else if (tab === 'photos-mine' || tab === 'photos-of-me') {
const type = tab === 'photos-mine' ? 'mine' : 'of-me';
body.innerHTML = `${tab === 'photos-mine' ? 'My Photos' : 'Photos of Me'} `;
const content = document.querySelector('#photosContent');
async function loadPhotos() {
content.innerHTML = ' Loading photos
';
try {
const data = await get(`${API}/admin/accounts/${accountId}/photos?type=${type}`);
if (!data.photos.length) { content.innerHTML = 'No photos found.
'; return; }
content.innerHTML = `${data.photoCount} photo(s)
${data.photos.map(p => `
${esc(p.path)}
${new Date(p.takenAt).toLocaleString()}
`).join('')}
`;
} catch (error) {
content.innerHTML = `${esc(error.message)}
`;
}
}
await loadPhotos();
} else if (tab === 'gifts') {
const isDev = currentUser?.isDeveloper === true;
body.innerHTML = `Pending Gifts ${isDev ? 'Clear All ' : ''}`;
const list = document.querySelector('#giftsList');
async function loadGifts() {
list.innerHTML = ' Loading pending gifts
';
try {
const gifts = await get(`${API}/admin/accounts/${accountId}/gifts`);
list.innerHTML = gifts.length ? gifts.map(g => `
${g.thumbnailImage ? `
` : ''}
${esc(g.friendlyName || (g.currency ? `${g.currency} tokens` : g.xp ? `${g.xp} XP` : 'Gift'))}
From ${esc(g.fromDisplayName)} · GiftContext ${g.giftContext}${g.consumableQuantity > 1 ? ` · x${g.consumableQuantity}` : ''}
${g.message ? `"${esc(g.message)}" ` : ''}
${isDev ? `
Clear ` : ''}
`).join('') : 'No pending gifts.
';
list.querySelectorAll('.clear-gift').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('[data-id]');
if (!confirm('Clear this pending gift? It cannot be recovered.')) return;
try {
await send(`${API}/admin/accounts/${accountId}/gifts/${row.dataset.id}`, 'DELETE');
await loadGifts();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
const clearAllBtn = document.querySelector('#clearAllGifts');
if (clearAllBtn) clearAllBtn.addEventListener('click', async () => {
const status = document.querySelector('#giftsStatus');
if (!confirm('Clear every pending gift for this player? This cannot be recovered.')) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/gifts`, 'DELETE');
status.textContent = `Cleared ${r.removedBoxes} gift(s).`;
status.classList.remove('error');
await loadGifts();
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
await loadGifts();
} else if (tab === 'troll') {
body.innerHTML = `Troll Loading
`;
let username = accountId;
try { username = (await get(`${API}/admin/accounts/${accountId}`)).username || accountId; } catch (error) { }
body.innerHTML = `Troll
Troll Developer-only. These are joke/punitive tools - use with care.
GRIM LABUBU ${esc(username)}
Send Fake Box
Kick to Room
Force Into My Instance
`;
const fakeBoxBtn = document.querySelector('[data-act="fake-box"]');
if (fakeBoxBtn) fakeBoxBtn.addEventListener('click', async () => {
if (!confirm('Send fake level-up box and ban this account? This is a developer-only troll feature.')) return;
const tokenAmount = Number(prompt('Fake token amount (for display only):', '100000') || 100000);
try {
const r = await send(`${API}/admin/accounts/${accountId}/troll/fakebox-and-ban`, 'POST', { tokenAmount, banImmediately: true });
alert(`Fake box sent with ${r.tokenAmount} tokens. Account banned.`);
await playerDetail(accountId);
} catch (error) { alert(error.message); }
});
const fakeBoxNoBanBtn = document.querySelector('[data-act="fake-box-no-ban"]');
if (fakeBoxNoBanBtn) fakeBoxNoBanBtn.addEventListener('click', async () => {
if (!confirm('Send a fake level-up box without banning this account? This is a developer-only troll feature.')) return;
const tokenAmount = Number(prompt('Fake token amount (for display only):', '100000') || 100000);
try {
const r = await send(`${API}/admin/accounts/${accountId}/troll/fakebox-and-ban`, 'POST', { tokenAmount, banImmediately: false });
alert(`Fake box sent with ${r.tokenAmount} tokens. Account not banned.`);
} catch (error) { alert(error.message); }
});
const kickToRoomBtn = document.querySelector('[data-act="kick-to-room"]');
if (kickToRoomBtn) kickToRoomBtn.addEventListener('click', async () => {
const roomId = prompt('Room ID (leave empty to kick to your current instance):');
if (roomId === null) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/troll/kick-to-room`, 'POST', { roomId: roomId ? Number(roomId) : null });
alert(`Kicked to room: ${r.roomName || 'your instance'}`);
} catch (error) { alert(error.message); }
});
const forceIntoBtn = document.querySelector('[data-act="force-into"]');
if (forceIntoBtn) forceIntoBtn.addEventListener('click', async () => {
if (!currentUser?.isDeveloper) return;
if (!confirm('Force this player into your current room instance?')) return;
try {
const r = await send(`${API}/admin/accounts/${accountId}/force-into/${currentUser.accountId}`, 'POST', {});
alert(r.delivered ? `Forced into "${r.roomName}" live.` : `Record updated to "${r.roomName}" - will apply on next connection.`);
} catch (error) { alert(error.message); }
});
} else {
body.innerHTML = `This tab isn't wired up yet.
`;
}
}
async function ageVerificationView() {
app.innerHTML = `Age Verification Players submit a photo (ID or face) against a code from the website; the photo goes to the staff Discord for manual review. Cross-check the code below against the Discord post before approving.
`;
const list = document.querySelector('#ageVerificationList');
async function load() {
list.innerHTML = ' Loading queue
';
try {
const queue = await get(`${API}/admin/age-verification`);
if (!queue.length) { list.innerHTML = 'Nothing awaiting review.
'; return; }
list.innerHTML = queue.map(item => `
${esc(item.displayName)} @${esc(item.username || '')} · ID ${item.accountId}
Code ${esc(item.code)} · ${esc(item.method === 'ManualId' ? 'Manual ID Verification' : 'Face Verification (Manually)')}${item.submittedAt ? ` · ${new Date(item.submittedAt).toLocaleString()}` : ''}
Approve
Reject
`).join('');
list.querySelectorAll('[data-act="approve"]').forEach(btn => btn.addEventListener('click', async () => {
const code = btn.closest('[data-code]').dataset.code;
if (!confirm(`Approve age verification for code ${code}? This clears their Junior status.`)) return;
try { await send(`${API}/admin/age-verification/${code}/approve`, 'POST', {}); await load(); }
catch (error) { alert(error.message); }
}));
list.querySelectorAll('[data-act="reject"]').forEach(btn => btn.addEventListener('click', async () => {
const code = btn.closest('[data-code]').dataset.code;
const reason = prompt('Reason for rejection (optional):') || null;
try { await send(`${API}/admin/age-verification/${code}/reject`, 'POST', { reason }); await load(); }
catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
await load();
}
async function liveInstances() {
app.innerHTML = `
Live Room Instances
`;
const list = document.querySelector('#instancesList');
async function loadInstances() {
list.innerHTML = ' Loading live instances
';
try {
const data = await get(`${API}/admin/instances`);
if (!data.instances.length) { list.innerHTML = 'No players currently online.
'; return; }
list.innerHTML = `${data.totalOnline} players online across ${data.instances.length} instances.
` + data.instances.map(instance => `
${esc(instance.room)} ${instance.roomId ? `(Room #${instance.roomId})` : ''}
Shutdown (Send to Dorm)
${instance.playerCount} player(s) · Instance ID: ${instance.roomInstanceId || 'N/A'}
${instance.players.map(p => `
${esc(p.displayName || p.username)}
@${esc(p.username)} · ${p.device} · ${p.sockets} socket(s)
View
`).join('')}
`).join('');
list.querySelectorAll('.shutdown-instance').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Shutdown this instance? All players will be sent to their dorm.')) return;
try {
await send(`${API}/admin/instances/${btn.dataset.instanceId}/shutdown`, 'POST', {});
await loadInstances();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
await loadInstances();
}
async function roomsList() {
app.innerHTML = `Rooms
Include dorms
Search
`;
const input = document.querySelector('#roomSearch'), results = document.querySelector('#roomListResults');
const includeDorms = document.querySelector('#roomIncludeDorms');
const pager = document.querySelector('#roomListPager');
let roomListSkip = 0;
const roomListTake = 20;
async function runSearch() {
results.innerHTML = ' Loading rooms
';
try {
const data = await get(`${API}/admin/rooms?search=${encodeURIComponent(input.value.trim())}&includeDorms=${includeDorms.checked}&skip=${roomListSkip}&take=${roomListTake}`);
const rooms = data.results ?? data;
const total = data.total ?? rooms.length;
results.innerHTML = rooms.length ? rooms.map(r => ` `).join('') : 'No rooms found.
';
pager.style.display = total > roomListTake ? 'flex' : 'none';
document.querySelector('#roomListPageInfo').textContent = total ? `${roomListSkip + 1}-${Math.min(roomListSkip + roomListTake, total)} of ${total}` : '';
document.querySelector('#roomListPrev').disabled = roomListSkip <= 0;
document.querySelector('#roomListNext').disabled = roomListSkip + roomListTake >= total;
} catch (error) {
results.innerHTML = `${esc(error.message)}
`;
}
}
const roomSearchGo = document.querySelector('#roomSearchGo');
if (roomSearchGo) roomSearchGo.addEventListener('click', () => { roomListSkip = 0; runSearch(); });
if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') { roomListSkip = 0; runSearch(); } });
if (includeDorms) includeDorms.addEventListener('change', () => { roomListSkip = 0; runSearch(); });
document.querySelector('#roomListPrev').addEventListener('click', () => { roomListSkip = Math.max(0, roomListSkip - roomListTake); runSearch(); });
document.querySelector('#roomListNext').addEventListener('click', () => { roomListSkip += roomListTake; runSearch(); });
await runSearch();
}
async function roomDetail(roomIdRaw) {
const roomId = Number(roomIdRaw);
app.innerHTML = ' Loading room
';
let room;
try { room = await get(`${API}/admin/rooms/${roomId}`); }
catch (error) { app.innerHTML = `← Back to Rooms `; return; }
app.innerHTML = `
← Back to Rooms
Details
Subrooms
Roles
Bans
`;
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab));
renderRoomTab(tab.dataset.tab, roomId);
}));
renderRoomTab('details', roomId);
}
const ROOM_FLAG_LABELS = {
cloningAllowed: 'Cloning allowed', disableMicAutoMute: 'Disable mic auto-mute',
disableRoomComments: 'Disable room comments', encryptVoiceChat: 'Encrypt voice chat',
toxmodEnabled: 'Toxmod enabled', loadScreenLocked: 'Lock loading screen',
autoLocalizeRoom: 'Auto-localize room', isDeveloperOwned: 'Developer owned',
supportsLevelVoting: 'Supports level voting', isRRO: 'Rec Room Original',
supportsScreens: 'Supports screens', supportsWalkVR: 'Walk VR',
supportsTeleportVR: 'Teleport VR', supportsVRLow: 'Low-end VR',
supportsQuest2: 'Quest 2', supportsMobile: 'Mobile', supportsJuniors: 'Junior accounts'
};
const ROOM_ASSIGNED_ROLES = ['None', 'Host', 'Moderator', 'CoOwner', 'TemporaryCoOwner', 'Banned'];
const ROOM_INVITED_ROLES = ['None', 'Host', 'Moderator', 'CoOwner', 'TemporaryCoOwner'];
async function renderRoomTab(tab, roomId) {
const body = document.querySelector('#tabBody');
if (tab === 'details') {
body.innerHTML = `Room Details
`;
try {
const room = await get(`${API}/admin/rooms/${roomId}`);
document.querySelector('#roomName').value = room.name || '';
document.querySelector('#roomImage').value = room.imageName || '';
document.querySelector('#roomAccessibility').value = room.accessibility || 'Public';
document.querySelector('#roomState').value = room.state || 'Active';
document.querySelector('#roomMaxPlayers').value = room.maxPlayers || 8;
document.querySelector('#roomMinLevel').value = room.minLevel || 0;
document.querySelector('#roomTags').value = (room.tags || []).join(', ');
document.querySelector('#roomDesc').value = room.description || '';
document.querySelectorAll('#roomFlagGrid [data-flag]').forEach(input => {
input.checked = !!room.flags?.[input.dataset.flag];
});
document.querySelector('#statCheers').value = room.stats?.cheers ?? 0;
document.querySelector('#statFavorites').value = room.stats?.favorites ?? 0;
document.querySelector('#statVisitors').value = room.stats?.visitors ?? 0;
document.querySelector('#statVisits').value = room.stats?.visits ?? 0;
document.querySelector('#roomSummaryCard').innerHTML = `Summary Accessibility: ${esc(room.accessibility)} · State: ${esc(room.state)} · Max players: ${room.maxPlayers} · Min level: ${room.minLevel}
Tags: ${esc((room.tags || []).join(', ') || 'none')}
`;
} catch (error) {
body.innerHTML = `${esc(error.message)}
`;
return;
}
applyRoleGating();
const saveRoomBtn = document.querySelector('#saveRoom');
if (saveRoomBtn) saveRoomBtn.addEventListener('click', async () => {
const status = document.querySelector('#roomStatus');
const flags = {};
document.querySelectorAll('#roomFlagGrid [data-flag]').forEach(input => { flags[input.dataset.flag] = input.checked; });
try {
status.textContent = 'Saving...';
status.classList.remove('error');
await send(`${API}/admin/rooms/${roomId}`, 'PUT', {
name: document.querySelector('#roomName').value,
description: document.querySelector('#roomDesc').value,
imageName: document.querySelector('#roomImage').value,
accessibility: document.querySelector('#roomAccessibility').value,
state: document.querySelector('#roomState').value,
maxPlayers: parseInt(document.querySelector('#roomMaxPlayers').value) || 1,
minLevel: parseInt(document.querySelector('#roomMinLevel').value) || 0,
tags: document.querySelector('#roomTags').value.split(',').map(t => t.trim()).filter(Boolean),
...flags
});
status.textContent = 'Saved successfully.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
const saveStatsBtn = document.querySelector('#saveStats');
if (saveStatsBtn) saveStatsBtn.addEventListener('click', async () => {
const status = document.querySelector('#statStatus');
try {
await send(`${API}/admin/rooms/${roomId}/stats`, 'PUT', {
cheers: parseInt(document.querySelector('#statCheers').value) || 0,
favorites: parseInt(document.querySelector('#statFavorites').value) || 0,
visitors: parseInt(document.querySelector('#statVisitors').value) || 0,
visits: parseInt(document.querySelector('#statVisits').value) || 0
});
status.textContent = 'Saved.'; status.classList.remove('error');
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
const transferBtn = document.querySelector('#transferOwner');
if (transferBtn) transferBtn.addEventListener('click', async () => {
const status = document.querySelector('#transferStatus');
const accountId = Number(document.querySelector('#newOwnerId').value);
if (!accountId) return;
if (!confirm(`Transfer ownership of this room to account ${accountId}? The current owner becomes CoOwner.`)) return;
try {
await send(`${API}/admin/rooms/${roomId}/transfer-owner`, 'POST', { accountId });
status.textContent = 'Transferred.'; status.classList.remove('error');
await roomDetail(roomId);
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
const changeIdBtn = document.querySelector('#changeRoomId');
if (changeIdBtn) changeIdBtn.addEventListener('click', async () => {
const status = document.querySelector('#changeRoomIdStatus');
const newRoomId = Number(document.querySelector('#newRoomId').value);
if (!newRoomId) return;
if (!confirm(`Change this room's ID from ${roomId} to ${newRoomId}? Anything holding the old ID directly (bookmarks, external links) will break.`)) return;
try {
await send(`${API}/admin/rooms/${roomId}/change-id`, 'POST', { newRoomId });
location.hash = `#rooms/${newRoomId}`;
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
} else if (tab === 'subrooms') {
body.innerHTML = `Subrooms
`;
async function loadSubrooms() {
const list = document.querySelector('#subroomList');
try {
const room = await get(`${API}/admin/rooms/${roomId}`);
if (!room.subRooms || !room.subRooms.length) { list.innerHTML = 'No subrooms.
'; return; }
list.innerHTML = room.subRooms.map(sr => `
${esc(sr.name || 'Untitled')} #${sr.subRoomId} · Save #${sr.currentSaveId || 0}${sr.hasData ? ' · Has data' : ''} · ${esc(sr.accessibility)}
`).join('');
applyRoleGating();
list.querySelectorAll('.save-subroom').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
try {
await send(`${API}/admin/rooms/${roomId}/subrooms/${id}`, 'PUT', {
name: card.querySelector('[data-field="name"]').value,
maxPlayers: parseInt(card.querySelector('[data-field="maxPlayers"]').value) || 1,
accessibility: card.querySelector('[data-field="accessibility"]').value,
isSandbox: card.querySelector('[data-field="isSandbox"]').checked,
unitySceneId: card.querySelector('[data-field="unitySceneId"]').value
});
await loadSubrooms();
} catch (error) { alert(error.message); }
}));
list.querySelectorAll('.clone-subroom').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
try { await send(`${API}/admin/rooms/${roomId}/subrooms/${id}/clone`, 'POST'); await loadSubrooms(); }
catch (error) { alert(error.message); }
}));
list.querySelectorAll('.delete-subroom').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
if (!confirm('Delete this subroom? A room must keep at least one.')) return;
try {
await send(`${API}/admin/rooms/${roomId}/subrooms/${id}`, 'DELETE');
await loadSubrooms();
} catch (error) { alert(error.message); }
}));
list.querySelectorAll('.save-blobs').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
try {
await send(`${API}/admin/rooms/${roomId}/subrooms/${id}/blobs`, 'PUT', {
roomBlob: card.querySelector('[data-field="roomBlob"]').value,
metadataBlob: card.querySelector('[data-field="metadataBlob"]').value
});
await loadSubrooms();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
applyRoleGating();
const createSubroomBtn = document.querySelector('#createSubroom');
if (createSubroomBtn) createSubroomBtn.addEventListener('click', async () => {
const status = document.querySelector('#createSubroomStatus');
const nameInput = document.querySelector('#newSubroomName');
const name = nameInput.value.trim();
if (!name) return;
try {
await send(`${API}/admin/rooms/${roomId}/subrooms`, 'POST', { name });
nameInput.value = '';
status.textContent = ''; status.classList.remove('error');
await loadSubrooms();
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
await loadSubrooms();
} else if (tab === 'roles') {
body.innerHTML = `Room Roles `;
applyRoleGating();
async function loadRoles() {
const list = document.querySelector('#roleList');
try {
const room = await get(`${API}/admin/rooms/${roomId}`);
if (!room.roles || !room.roles.length) { list.innerHTML = 'No roles assigned.
'; return; }
list.innerHTML = room.roles.map(r => `
${esc(r.player?.displayName || r.player?.username || r.accountId)} ${esc(r.role)}${r.invitedRole && r.invitedRole !== 'None' ? ` · Invited: ${esc(r.invitedRole)}` : ''}
Remove
`).join('');
applyRoleGating();
list.querySelectorAll('.delete-role').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Remove this role?')) return;
try {
await send(`${API}/admin/rooms/${roomId}/roles/${btn.dataset.id}`, 'DELETE');
await loadRoles();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
const addRoleBtn = document.querySelector('#addRole');
if (addRoleBtn) addRoleBtn.addEventListener('click', async () => {
const accountId = document.querySelector('#roleAccountId');
const roleType = document.querySelector('#roleType');
const invitedRoleType = document.querySelector('#invitedRoleType');
const accId = Number(accountId.value);
if (!accId) return;
try {
await send(`${API}/admin/rooms/${roomId}/roles/${accId}`, 'PUT', { role: roleType.value, invitedRole: invitedRoleType.value });
accountId.value = '';
await loadRoles();
} catch (error) { alert(error.message); }
});
await loadRoles();
} else if (tab === 'bans') {
body.innerHTML = `Room Bans
Add Ban Ban Player `;
async function loadBans() {
const list = document.querySelector('#banList');
try {
const room = await get(`${API}/admin/rooms/${roomId}`);
if (!room.bans || !room.bans.length) { list.innerHTML = 'No bans.
'; return; }
list.innerHTML = room.bans.map(b => `
${esc(b.displayName || b.username || b.accountId)} ${esc(b.reason || 'No reason')}
Unban
`).join('');
list.querySelectorAll('.delete-ban').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Unban this player?')) return;
try {
await send(`${API}/admin/rooms/${roomId}/bans/${btn.dataset.id}`, 'DELETE');
await loadBans();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
const addBanBtn = document.querySelector('#addBan');
if (addBanBtn) addBanBtn.addEventListener('click', async () => {
const accountId = document.querySelector('#banAccountId');
const banReason = document.querySelector('#banReason');
if (!accountId || !banReason) return;
const accId = Number(accountId.value);
if (!accId) return;
try {
await send(`${API}/admin/rooms/${roomId}/bans`, 'POST', { accountId: accId, reason: banReason.value });
accountId.value = '';
banReason.value = '';
await loadBans();
} catch (error) { alert(error.message); }
});
await loadBans();
}
}
async function playerEventsView() {
app.innerHTML = `Player Events
`;
const evTitle = document.querySelector('#evTitle');
const evDesc = document.querySelector('#evDesc');
const evStart = document.querySelector('#evStart');
const evEnd = document.querySelector('#evEnd');
const evImageFile = document.querySelector('#evImageFile');
const evImageName = document.querySelector('#evImageName');
const evImagePreview = document.querySelector('#evImagePreview');
const evPinned = document.querySelector('#evPinned');
const evEditId = document.querySelector('#evEditId');
const evFormTitle = document.querySelector('#evFormTitle');
const evCreateBtn = document.querySelector('#evCreate');
const evCancelEditBtn = document.querySelector('#evCancelEdit');
evImageFile.addEventListener('change', async () => {
const status = document.querySelector('#evStatus');
const file = evImageFile.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const r = await fetch(`${API}/admin/events/image`, { method: 'POST', body: formData, credentials: 'same-origin' });
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Upload failed');
evImageName.value = data.path;
evImagePreview.innerHTML = ` `;
} catch (error) { status.textContent = error.message; status.classList.add('error'); }
});
function resetForm() {
evEditId.value = '';
evFormTitle.textContent = 'New event';
evCreateBtn.textContent = 'Create event';
evCancelEditBtn.hidden = true;
evTitle.value = ''; evDesc.value = ''; evStart.value = ''; evEnd.value = '';
evImageFile.value = ''; evImageName.value = ''; evImagePreview.innerHTML = '';
evPinned.checked = false;
}
function toLocalInputValue(iso) {
if (!iso) return '';
const d = new Date(iso);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
function editEvent(e) {
evEditId.value = e.eventId ?? e.id;
evFormTitle.textContent = `Editing "${e.title}"`;
evCreateBtn.textContent = 'Save changes';
evCancelEditBtn.hidden = false;
evTitle.value = e.title;
evDesc.value = e.description || '';
evStart.value = toLocalInputValue(e.startsAt);
evEnd.value = toLocalInputValue(e.endsAt);
evImageName.value = e.image ? decodeURIComponent(e.image.replace('/imageserver/', '')) : '';
evImagePreview.innerHTML = e.image ? ` ` : '';
evPinned.checked = !!e.pinned;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function load() {
const list = document.querySelector('#evList');
try {
const items = await get(`${API}/admin/events`);
if (!items.length) { list.innerHTML = 'No events yet.
'; return; }
list.innerHTML = items.map(e => `${e.image ? `
` : ''}
${esc(e.title)} ${e.pinned ? '📌' : ''} ${esc(e.description)}
${e.startsAt ? new Date(e.startsAt).toLocaleString() : ''}${e.endsAt ? ' - ' + new Date(e.endsAt).toLocaleString() : ''}
Edit Delete
`).join('');
list.querySelectorAll('.edit-ev').forEach((btn, i) => btn.addEventListener('click', () => editEvent(items[i])));
list.querySelectorAll('.delete-ev').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
if (!confirm('Delete this event?')) return;
try { await send(`${API}/admin/events/${id}`, 'DELETE'); card.remove(); }
catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
if (evCreateBtn) evCreateBtn.addEventListener('click', async () => {
const status = document.querySelector('#evStatus');
const title = evTitle.value.trim();
const description = evDesc.value.trim();
const startsAt = evStart.value;
const endsAt = evEnd.value;
if (!title || !startsAt) { status.textContent = 'Title and start time are required.'; status.classList.add('error'); return; }
const payload = {
title, description,
imageName: evImageName.value || null,
startsAt: new Date(startsAt).toISOString(),
endsAt: endsAt ? new Date(endsAt).toISOString() : null,
pinned: evPinned.checked
};
try {
if (evEditId.value) {
await send(`${API}/admin/events/${evEditId.value}`, 'PUT', payload);
status.textContent = 'Saved.';
} else {
await send(`${API}/admin/events`, 'POST', payload);
status.textContent = 'Created.';
}
status.classList.remove('error');
resetForm();
await load();
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
if (evCancelEditBtn) evCancelEditBtn.addEventListener('click', resetForm);
await load();
}
function simpleMarkdownPreview(markdown) {
return esc(markdown)
.replace(/\*\*(.+?)\*\*/g, '$1 ')
.replace(/\*(.+?)\*/g, '$1 ')
.replace(/\n/g, ' ');
}
const ANNOUNCEMENT_KINDS = ['info', 'update', 'warning', 'maintenance'];
async function announcementsView() {
app.innerHTML = `Announcements
`;
const annTitle = document.querySelector('#annTitle');
const annKind = document.querySelector('#annKind');
const annBody = document.querySelector('#annBody');
const annPreview = document.querySelector('#annPreview');
const annPinned = document.querySelector('#annPinned');
const annPublished = document.querySelector('#annPublished');
const annEditId = document.querySelector('#annEditId');
const annFormTitle = document.querySelector('#annFormTitle');
const annCreateBtn = document.querySelector('#annCreate');
const annCancelEditBtn = document.querySelector('#annCancelEdit');
annBody.addEventListener('input', () => { annPreview.innerHTML = simpleMarkdownPreview(annBody.value) || 'Nothing to preview yet. '; });
function resetForm() {
annEditId.value = '';
annFormTitle.textContent = 'New announcement';
annCreateBtn.textContent = 'Post announcement';
annCancelEditBtn.hidden = true;
annTitle.value = '';
annKind.value = 'info';
annBody.value = '';
annPreview.innerHTML = '';
annPinned.checked = false;
annPublished.checked = true;
}
function editAnnouncement(a) {
annEditId.value = a.id;
annFormTitle.textContent = `Editing "${a.title}"`;
annCreateBtn.textContent = 'Save changes';
annCancelEditBtn.hidden = false;
annTitle.value = a.title;
annKind.value = a.kind;
annBody.value = a.bodyMarkdown;
annPreview.innerHTML = simpleMarkdownPreview(a.bodyMarkdown);
annPinned.checked = a.pinned;
annPublished.checked = a.published;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function load() {
const list = document.querySelector('#annList');
try {
const items = await get(`${API}/admin/announcements`);
if (!items.length) { list.innerHTML = 'No announcements yet.
'; return; }
list.innerHTML = items.map(a => `${esc(a.title)} ${esc(a.kind)} ${a.pinned ? '📌' : ''} ${a.published ? '' : 'Draft '} ${esc(a.bodyMarkdown)}
Edit Delete
`).join('');
list.querySelectorAll('.edit-ann').forEach((btn, i) => btn.addEventListener('click', () => editAnnouncement(items[i])));
list.querySelectorAll('.delete-ann').forEach(btn => btn.addEventListener('click', async () => {
const card = btn.closest('[data-id]'), id = card.dataset.id;
if (!confirm('Delete this announcement?')) return;
try { await send(`${API}/admin/announcements/${id}`, 'DELETE'); card.remove(); }
catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
if (annCreateBtn) annCreateBtn.addEventListener('click', async () => {
const status = document.querySelector('#annStatus');
const title = annTitle.value.trim();
const bodyMarkdown = annBody.value.trim();
if (!title || !bodyMarkdown) { status.textContent = 'Title and body are required.'; status.classList.add('error'); return; }
const payload = { title, bodyMarkdown, kind: annKind.value, pinned: annPinned.checked, published: annPublished.checked };
try {
if (annEditId.value) {
await send(`${API}/admin/announcements/${annEditId.value}`, 'PUT', payload);
status.textContent = 'Saved.';
} else {
await send(`${API}/admin/announcements`, 'POST', payload);
status.textContent = 'Posted.';
}
status.classList.remove('error');
resetForm();
await load();
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
if (annCancelEditBtn) annCancelEditBtn.addEventListener('click', resetForm);
await load();
}
async function communityBoardView() {
app.innerHTML = 'Community Board Loading board
';
try {
const board = await get(`${API}/admin/community-board`);
renderCommunityBoard(board);
} catch (error) {
app.innerHTML = `Community Board `;
}
}
function renderCommunityBoard(board) {
app.innerHTML = `
Community Board
Featured Player
Player ID
Title Override
URL Override
Featured Rooms
+ Add Room
Instagram Images
+ Add Image
Current Announcement
Message
More Info URL
Save All Changes
Reset to Server Data
`;
const fpId = document.querySelector('#fpId');
const fpTitle = document.querySelector('#fpTitle');
const fpUrl = document.querySelector('#fpUrl');
const annMessage = document.querySelector('#annMessage');
const annUrl = document.querySelector('#annUrl');
let featuredRooms = [...(board.featuredRoomGroup?.featuredRooms || [])];
let instagramImages = [...(board.instagramImages || [])];
let videos = [...(board.videos || [])];
function renderFeaturedRooms() {
const container = document.querySelector('#featuredRoomsList');
container.innerHTML = featuredRooms.map((room, i) => `
Room ID
Room Name
Image Name
Remove
`).join('');
container.querySelectorAll('.remove-fr').forEach(btn => btn.addEventListener('click', () => {
featuredRooms.splice(btn.dataset.index, 1);
renderFeaturedRooms();
}));
container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => {
const idx = e.target.dataset.index;
const field = e.target.classList.contains('fr-roomId') ? 'roomId' :
e.target.classList.contains('fr-roomName') ? 'roomName' : 'imageName';
featuredRooms[idx][field] = e.target.value;
}));
}
function renderInstagram() {
const container = document.querySelector('#instagramList');
container.innerHTML = instagramImages.map((img, i) => `
Image URL
Remove
`).join('');
container.querySelectorAll('.remove-insta').forEach(btn => btn.addEventListener('click', () => {
instagramImages.splice(btn.dataset.index, 1);
renderInstagram();
}));
container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => {
instagramImages[e.target.dataset.index].imageUrl = e.target.value;
}));
}
function renderVideos() {
const container = document.querySelector('#videosList');
container.innerHTML = videos.map((vid, i) => `
Title
Description
Remove
`).join('');
container.querySelectorAll('.remove-vid').forEach(btn => btn.addEventListener('click', () => {
videos.splice(btn.dataset.index, 1);
renderVideos();
}));
container.querySelectorAll('input').forEach(input => input.addEventListener('change', (e) => {
const idx = e.target.dataset.index;
const field = e.target.classList.contains('vid-title') ? 'title' : 'description';
videos[idx][field] = e.target.value;
}));
}
renderFeaturedRooms();
renderInstagram();
renderVideos();
const addFeaturedRoomBtn = document.querySelector('#addFeaturedRoom');
if (addFeaturedRoomBtn) addFeaturedRoomBtn.addEventListener('click', () => {
featuredRooms.push({ roomId: '', roomName: '', imageName: '' });
renderFeaturedRooms();
});
const addInstagramBtn = document.querySelector('#addInstagram');
if (addInstagramBtn) addInstagramBtn.addEventListener('click', () => {
instagramImages.push({ imageUrl: '' });
renderInstagram();
});
const addVideoBtn = document.querySelector('#addVideo');
if (addVideoBtn) addVideoBtn.addEventListener('click', () => {
videos.push({ title: '', description: '' });
renderVideos();
});
applyRoleGating();
const startMaintenanceBtn = document.querySelector('#startMaintenance');
if (startMaintenanceBtn) startMaintenanceBtn.addEventListener('click', async () => {
const status = document.querySelector('#maintenanceStatus');
const minutes = parseInt(document.querySelector('#maintenanceMinutes').value) || 0;
if (minutes > 0 && !confirm(`Push a live ${minutes}-minute maintenance countdown to every player in-game?`)) return;
try {
const r = await send(`${API}/admin/maintenance`, 'POST', { minutes });
status.textContent = r.message;
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
const saveBoardBtn = document.querySelector('#saveBoard');
if (saveBoardBtn) saveBoardBtn.addEventListener('click', async () => {
const status = document.querySelector('#boardStatus');
if (!status) return;
const newBoard = {
featuredPlayer: fpId.value ? { id: Number(fpId.value), titleOverride: fpTitle.value, urlOverride: fpUrl.value } : null,
featuredRoomGroup: featuredRooms.length ? { featuredRooms } : null,
instagramImages: instagramImages,
videos: videos,
currentAnnouncement: annMessage.value ? { message: annMessage.value, moreInfoUrl: annUrl.value } : null
};
status.textContent = 'Saving...';
status.classList.remove('error');
try {
await send(`${API}/admin/community-board`, 'PUT', newBoard);
status.textContent = 'Saved successfully.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
const resetBoardBtn = document.querySelector('#resetBoard');
if (resetBoardBtn) resetBoardBtn.addEventListener('click', async () => {
const status = document.querySelector('#boardStatus');
if (!status) return;
status.textContent = 'Reloading...';
status.classList.remove('error');
try {
const freshBoard = await get(`${API}/admin/community-board`);
renderCommunityBoard(freshBoard);
status.textContent = 'Reset to server data.';
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
}
async function clubsView() {
app.innerHTML = `Clubs Search
`;
const input = document.querySelector('#clubSearch'), results = document.querySelector('#clubListResults');
async function loadClubs() {
results.innerHTML = ' Loading clubs
';
try {
const data = await get(`${API}/admin/clubs?search=${encodeURIComponent(input.value.trim())}`);
if (!data.clubs.length) { results.innerHTML = 'No clubs found.
'; return; }
results.innerHTML = data.clubs.map(c => `
`).join('');
} catch (error) {
results.innerHTML = `${esc(error.message)}
`;
}
}
const clubSearchGo = document.querySelector('#clubSearchGo');
if (clubSearchGo) clubSearchGo.addEventListener('click', loadClubs);
if (input) input.addEventListener('keydown', e => { if (e.key === 'Enter') loadClubs(); });
await loadClubs();
}
async function clubDetail(clubIdRaw) {
const clubId = Number(clubIdRaw);
app.innerHTML = ' Loading club
';
let club;
try { club = await get(`${API}/admin/clubs/${clubId}`); }
catch (error) { app.innerHTML = `← Back to Clubs `; return; }
const summary = club.summary;
app.innerHTML = `
← Back to Clubs
`;
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === tab));
renderClubTab(tab.dataset.tab, clubId);
}));
renderClubTab('members', clubId);
const editBtn = document.querySelector('[data-act="edit"]');
if (editBtn) editBtn.addEventListener('click', async () => {
const name = prompt('Club name:', summary.name);
if (name === null) return;
const description = prompt('Description:', summary.description || '');
if (description === null) return;
const state = prompt('State (Active/MarkedForDelete):', summary.state);
if (state === null) return;
const visibility = prompt('Visibility (Public/Private):', summary.visibility);
if (visibility === null) return;
try {
await send(`${API}/admin/clubs/${clubId}`, 'PUT', { name, description, state, visibility });
await clubDetail(clubIdRaw);
} catch (error) { alert(error.message); }
});
}
async function renderClubTab(tab, clubId) {
const body = document.querySelector('#tabBody');
if (tab === 'members') {
body.innerHTML = `Members `;
try {
const members = await get(`${API}/admin/clubs/${clubId}/members`);
if (!members.length) { body.innerHTML = 'No members.
'; return; }
body.innerHTML = `Account ID Username Display Name Role Actions ${members.map(m => `
${m.accountId}
${esc(m.username || 'N/A')}
${esc(m.displayName || 'N/A')}
${esc(m.membershipType)}
Set Role Remove
`).join('')}
`;
body.querySelectorAll('.set-role').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('tr'), accountId = row.dataset.account;
const role = prompt('Membership type (Creator/Admin/Officer/Member):');
if (!role) return;
try { await send(`${API}/admin/clubs/${clubId}/members/${accountId}`, 'POST', { membershipType: role }); await renderClubTab('members', clubId); }
catch (error) { alert(error.message); }
}));
body.querySelectorAll('.remove-member').forEach(btn => btn.addEventListener('click', async () => {
const row = btn.closest('tr'), accountId = row.dataset.account;
if (!confirm('Remove this member?')) return;
try { await send(`${API}/admin/clubs/${clubId}/members/${accountId}`, 'DELETE'); await renderClubTab('members', clubId); }
catch (error) { alert(error.message); }
}));
} catch (error) {
body.innerHTML = `${esc(error.message)}
`;
}
}
}
const GIFT_CONTEXT_DEFS = [['None', -1], ['Default'], ['First_Activity'], ['Game_Drop'], ['All_Daily_Challenges_Complete'], ['All_Weekly_Challenge_Complete'], ['Daily_Challenge_Complete'], ['Weekly_Challenge_Complete'], ['Unassigned_Equipment', 10], ['Unassigned_Avatar'], ['Unassigned_Consumable'], ['Reacquisition', 20], ['Membership'], ['NUX_TokensAndDressUp', 30], ['NUX_Experiment1'], ['NUX_Experiment2'], ['NUX_Experiment3'], ['NUX_Experiment4'], ['NUX_Experiment5'], ['GameRewards', 50], ['GameRewards_Tokens'], ['LevelUp', 100], ['Purchased_Gift_A', 500], ['Purchased_Gift_B'], ['Purchased_Gift_C'], ['Purchased_Gift_D'], ['Holiday', 1000], ['Contest'], ['Promotion'], ['SubscribersOnly'], ['Deprecated', 1100], ['RecRoyale', 1200], ['DEPRECATED_Paintball_ClearCut', 2000], ['DEPRECATED_Paintball_Homestead'], ['DEPRECATED_Paintball_Quarry'], ['DEPRECATED_Paintball_River'], ['DEPRECATED_Paintball_Dam'], ['DEPRECATED_Paintball_DriveIn'], ['Paintball_ClearCut', 2010], ['Paintball_Homestead'], ['Paintball_Quarry'], ['Paintball_River'], ['Paintball_Dam'], ['Paintball_DriveIn'], ['DEPRECATED_Discgolf_Propulsion', 3000], ['DEPRECATED_Discgolf_Lake'], ['Discgolf_Propulsion', 3010], ['Discgolf_Lake'], ['Discgolf_Mode_CoopCatch', 3500], ['Quest_Goblin_A', 4000], ['Quest_Goblin_B'], ['Quest_Goblin_C'], ['Quest_Goblin_S'], ['Quest_Goblin_Consumable'], ['Quest_Cauldron_A', 4010], ['Quest_Cauldron_B'], ['Quest_Cauldron_C'], ['Quest_Cauldron_S'], ['Quest_Cauldron_Consumable'], ['Quest_Pirate1_A', 4100], ['Quest_Pirate1_B'], ['Quest_Pirate1_C'], ['Quest_Pirate1_S'], ['Quest_Pirate1_X'], ['Quest_Pirate1_Consumable'], ['Quest_Dracula1_A', 4200], ['Quest_Dracula1_B'], ['Quest_Dracula1_C'], ['Quest_Dracula1_S'], ['Quest_Dracula1_X'], ['Quest_Dracula1_Consumable'], ['Quest_Dracula1_SS'], ['Quest_SciFi_A', 4500], ['Quest_SciFi_B'], ['Quest_SciFi_C'], ['Quest_SciFi_S'], ['Quest_Scifi_Consumable'], ['DEPRECATED_Charades', 5000], ['Charades'], ['DEPRECATED_Soccer', 6000], ['Soccer'], ['DEPRECATED_Paddleball', 7000], ['Paddleball'], ['DEPRECATED_Dodgeball', 8000], ['Dodgeball'], ['DEPRECATED_Lasertag', 9000], ['Lasertag'], ['DEPRECATED_Bowling', 10000], ['Bowling'], ['StuntRunner_TheMainEvent_A', 11000], ['StuntRunner_TheMainEvent_B'], ['StuntRunner_TheMainEvent_C'], ['StuntRunner_TheMainEvent_D'], ['StuntRunner_TheMainEvent_S'], ['StuntRunner_TheMainEvent_X'], ['StuntRunner_TheMainEvent_Consumable'], ['StuntRunner_TheMainEvent_SS'], ['Store_LaserTag', 100000], ['Store_RecCenter', 100010], ['Consumable', 110000], ['Token', 110100], ['Punchcard_Challenge_Complete', 110200], ['All_Punchcard_Challenges_Complete'], ['Commerce_Purchase', 200000]];
const GIFT_CONTEXTS = (() => { let next = 0; return GIFT_CONTEXT_DEFS.map(([name, value]) => { const v = value !== undefined ? value : next; next = v + 1; return { name, value: v }; }); })();
async function coachGiftingView() {
app.innerHTML = `
Coach Gifting
Send Gift
Gift Type
Avatar Item
Equipment
Consumable
Tokens
XP
Mystery Box
Recipient Account IDs (comma-separated, or leave empty to send to all)
SKU ID
Amount
Box Rarity (10=Common, 20=Uncommon, 30=Rare, 40=Epic, 50=Legendary)
Common (10)
Uncommon (20)
Rare (30)
Epic (40)
Legendary (50)
Box DesignNormal Friendotron Custom...
Message (optional)
Send to all players
Online only
Send Gift
Gift Catalog
All Types
Avatar
Equipment
Consumable
Search
← Prev
Next →
`;
const giftType = document.querySelector('#giftType');
const skuLabel = document.querySelector('#skuLabel');
const amountLabel = document.querySelector('#amountLabel');
const boxRarityLabel = document.querySelector('#boxRarityLabel');
const skuId = document.querySelector('#skuId');
const amount = document.querySelector('#amount');
const boxRarity = document.querySelector('#boxRarity');
const boxDesign = document.querySelector('#boxDesign');
const boxDesignCustom = document.querySelector('#boxDesignCustom');
(function populateGiftContexts() {
const customOption = boxDesign.querySelector('option[value="custom"]');
for (const { name, value } of GIFT_CONTEXTS) {
if (value === 2 || value === 110000) continue;
const opt = document.createElement('option');
opt.value = String(value);
opt.textContent = `${name} (${value})`;
boxDesign.insertBefore(opt, customOption);
}
})();
function resolveBoxDesign() {
return boxDesign.value === 'custom' ? Number(boxDesignCustom.value || 0) : Number(boxDesign.value);
}
function updateFormVisibility() {
const type = giftType.value;
skuLabel.style.display = (type === 'avatar' || type === 'equipment' || type === 'consumable') ? '' : 'none';
amountLabel.style.display = (type === 'tokens' || type === 'xp' || type === 'consumable') ? '' : 'none';
boxRarityLabel.style.display = type === 'box' ? '' : 'none';
boxDesign.parentElement.style.display = type === 'box' ? '' : 'none';
boxDesignCustom.hidden = !(type === 'box' && boxDesign.value === 'custom');
}
if (giftType) giftType.addEventListener('change', updateFormVisibility);
if (boxDesign) boxDesign.addEventListener('change', updateFormVisibility);
updateFormVisibility();
let catalogSkip = 0;
const catalogTake = 20;
async function loadCatalog() {
const catalogSearch = document.querySelector('#catalogSearch');
const catalogType = document.querySelector('#catalogType');
const results = document.querySelector('#catalogResults');
const pageInfo = document.querySelector('#catalogPageInfo');
const prevBtn = document.querySelector('#catalogPrev');
const nextBtn = document.querySelector('#catalogNext');
if (!catalogSearch || !catalogType || !results) return;
const search = catalogSearch.value;
const type = catalogType.value;
results.innerHTML = ' Loading catalog
';
try {
const data = await get(`${API}/admin/gifts/catalog?search=${encodeURIComponent(search)}&type=${type}&skip=${catalogSkip}&take=${catalogTake}`);
const items = data.results ?? data;
const total = data.total ?? items.length;
if (!items.length) { results.innerHTML = 'No items found.
'; }
else {
results.innerHTML = items.map(item => `
${esc(item.friendlyName)} SKU ${item.skuId} · ${item.type} ${item.avatarItemId ? `· Avatar #${item.avatarItemId}` : ''}
Select
`).join('');
results.querySelectorAll('.select-sku').forEach(btn => btn.addEventListener('click', () => {
skuId.value = btn.closest('[data-sku]').dataset.sku;
}));
}
if (pageInfo) pageInfo.textContent = total ? `${catalogSkip + 1}-${Math.min(catalogSkip + catalogTake, total)} of ${total}` : '';
if (prevBtn) prevBtn.disabled = catalogSkip <= 0;
if (nextBtn) nextBtn.disabled = catalogSkip + catalogTake >= total;
} catch (error) {
results.innerHTML = `${esc(error.message)}
`;
}
}
const searchCatalogBtn = document.querySelector('#searchCatalog');
if (searchCatalogBtn) searchCatalogBtn.addEventListener('click', () => { catalogSkip = 0; loadCatalog(); });
const catalogSearch = document.querySelector('#catalogSearch');
if (catalogSearch) catalogSearch.addEventListener('keydown', e => { if (e.key === 'Enter') { catalogSkip = 0; loadCatalog(); } });
const catalogPrevBtn = document.querySelector('#catalogPrev');
if (catalogPrevBtn) catalogPrevBtn.addEventListener('click', () => { catalogSkip = Math.max(0, catalogSkip - catalogTake); loadCatalog(); });
const catalogNextBtn = document.querySelector('#catalogNext');
if (catalogNextBtn) catalogNextBtn.addEventListener('click', () => { catalogSkip += catalogTake; loadCatalog(); });
loadCatalog();
const sendGiftBtn = document.querySelector('#sendGift');
if (sendGiftBtn) sendGiftBtn.addEventListener('click', async () => {
const status = document.querySelector('#giftStatus');
const recipientIds = document.querySelector('#recipientIds');
const sendToAll = document.querySelector('#sendToAll');
const onlineOnly = document.querySelector('#onlineOnly');
const giftMessage = document.querySelector('#giftMessage');
if (!status || !recipientIds || !sendToAll || !onlineOnly || !giftMessage) return;
const recipientAccountIds = recipientIds.value ? recipientIds.value.split(',').map(id => Number(id.trim())).filter(id => id > 0) : [];
const request = {
GiftType: giftType.value,
RecipientAccountIds: recipientAccountIds,
SendToAll: sendToAll.checked,
OnlineOnly: onlineOnly.checked,
Message: giftMessage.value
};
if (giftType.value === 'avatar' || giftType.value === 'equipment' || giftType.value === 'consumable') {
request.SkuId = Number(skuId.value) || 0;
if (giftType.value === 'consumable') request.Amount = Number(amount.value) || 1;
} else if (giftType.value === 'tokens' || giftType.value === 'xp') {
request.Amount = Number(amount.value) || 0;
} else if (giftType.value === 'box') {
request.BoxRarity = Number(boxRarity.value) || 10;
request.BoxDesign = resolveBoxDesign() || 2;
}
status.textContent = 'Sending...';
status.classList.remove('error');
try {
const result = await send(`${API}/admin/gifts`, 'POST', request);
status.textContent = `Gift sent to ${result.queued} players.`;
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
}
async function configView() {
app.innerHTML = `
Server Security
`;
const accountCreationToggle = document.querySelector('#accountCreationEnabled');
const signupToggle = document.querySelector('#signupEnabled');
const vpnToggle = document.querySelector('#vpnBlockingEnabled');
const proxyCheckHint = document.querySelector('#proxyCheckHint');
const status = document.querySelector('#configStatus');
async function loadSettings() {
try {
const settings = await get(`${API}/admin/settings`);
accountCreationToggle.checked = !!settings.accountCreationEnabled;
signupToggle.checked = !!settings.recNetSignupEnabled;
vpnToggle.checked = !!settings.vpnBlockingEnabled;
proxyCheckHint.textContent = `Proxy-check API key: ${settings.proxyCheckConfigured ? 'configured' : 'not configured (set in server config file)'}`;
status.textContent = `Account creation ${settings.accountCreationEnabled ? 'enabled' : 'disabled'} · RecNet signup ${settings.recNetSignupEnabled ? 'enabled' : 'disabled'} · VPN blocking ${settings.vpnBlockingEnabled ? 'enabled' : 'disabled'}`;
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
}
async function saveSettings() {
try {
accountCreationToggle.disabled = signupToggle.disabled = vpnToggle.disabled = true;
status.textContent = 'Saving...';
status.classList.remove('error');
await send(`${API}/admin/settings`, 'PUT', {
accountCreationEnabled: accountCreationToggle.checked,
recNetSignupEnabled: signupToggle.checked,
vpnBlockingEnabled: vpnToggle.checked
});
status.textContent = 'Settings saved.';
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
} finally {
accountCreationToggle.disabled = signupToggle.disabled = vpnToggle.disabled = false;
}
}
accountCreationToggle.addEventListener('change', saveSettings);
signupToggle.addEventListener('change', saveSettings);
vpnToggle.addEventListener('change', saveSettings);
await loadSettings();
}
async function accountCreationView() {
await configView();
}
async function avatarItemsView() {
app.innerHTML = `
Avatar Items
`;
let avatarBrowseSkip = 0;
const avatarBrowseTake = 20;
async function runAvatarBrowse() {
const searchInput = document.querySelector('#avatarBrowseSearch');
const results = document.querySelector('#avatarBrowseResults');
const pager = document.querySelector('#avatarBrowsePager');
const query = searchInput.value.trim();
results.innerHTML = ' Loading
';
try {
const data = await get(`${API}/admin/gifts/catalog?search=${encodeURIComponent(query)}&type=avatar&skip=${avatarBrowseSkip}&take=${avatarBrowseTake}`);
const items = data.results ?? data;
const total = data.total ?? items.length;
results.innerHTML = items.length ? items.map(item => `
${item.thumbnailImage ? `
` : ''}
${esc(item.friendlyName)} SKU ${item.skuId} · Avatar #${item.avatarItemId} · Rarity ${item.rarity}
`).join('') : 'No items found.
';
pager.style.display = total > avatarBrowseTake ? 'flex' : 'none';
document.querySelector('#avatarBrowsePageInfo').textContent = total ? `${avatarBrowseSkip + 1}-${Math.min(avatarBrowseSkip + avatarBrowseTake, total)} of ${total}` : '';
document.querySelector('#avatarBrowsePrev').disabled = avatarBrowseSkip <= 0;
document.querySelector('#avatarBrowseNext').disabled = avatarBrowseSkip + avatarBrowseTake >= total;
} catch (error) {
results.innerHTML = `${esc(error.message)}
`;
}
}
const avatarBrowseSearchGo = document.querySelector('#avatarBrowseSearchGo');
if (avatarBrowseSearchGo) avatarBrowseSearchGo.addEventListener('click', () => { avatarBrowseSkip = 0; runAvatarBrowse(); });
const avatarBrowseSearchInput = document.querySelector('#avatarBrowseSearch');
if (avatarBrowseSearchInput) avatarBrowseSearchInput.addEventListener('keydown', e => { if (e.key === 'Enter') { avatarBrowseSkip = 0; runAvatarBrowse(); } });
document.querySelector('#avatarBrowsePrev').addEventListener('click', () => { avatarBrowseSkip = Math.max(0, avatarBrowseSkip - avatarBrowseTake); runAvatarBrowse(); });
document.querySelector('#avatarBrowseNext').addEventListener('click', () => { avatarBrowseSkip += avatarBrowseTake; runAvatarBrowse(); });
const simpleMode = document.querySelector('#simpleMode');
const advancedMode = document.querySelector('#advancedMode');
const fileInput = document.querySelector('#avatarJsonFile');
const textArea = document.querySelector('#avatarJsonText');
const status = document.querySelector('#avatarStatus');
document.querySelectorAll('input[name="avatarMode"]').forEach(radio => {
radio.addEventListener('change', () => {
simpleMode.style.display = radio.value === 'simple' ? '' : 'none';
advancedMode.style.display = radio.value === 'advanced' ? '' : 'none';
});
});
if (fileInput) fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const avatarTextArea = document.querySelector('#avatarJsonText');
if (avatarTextArea) avatarTextArea.value = e.target.result;
};
reader.readAsText(file);
});
const uploadAvatarItemBtn = document.querySelector('#uploadAvatarItem');
if (uploadAvatarItemBtn) uploadAvatarItemBtn.addEventListener('click', async () => {
let json;
try {
const simpleMode = document.querySelector('#simpleMode');
const advancedMode = document.querySelector('#advancedMode');
const avatarFileInput = document.querySelector('#avatarJsonFile');
const avatarTextArea = document.querySelector('#avatarJsonText');
if (!simpleMode || !advancedMode || !avatarFileInput || !avatarTextArea) return;
if (simpleMode.style.display !== 'none') {
const file = avatarFileInput.files[0];
if (!file) { alert('Please select a JSON file.'); return; }
const text = await file.text();
json = JSON.parse(text);
} else {
const text = avatarTextArea.value.trim();
if (!text) { alert('Please enter JSON.'); return; }
json = JSON.parse(text);
}
} catch (error) {
alert('Invalid JSON: ' + error.message);
return;
}
try {
const status = document.querySelector('#avatarStatus');
if (!status) return;
status.textContent = 'Uploading...';
status.classList.remove('error');
const result = await send(`${API}/admin/avatar-items`, 'POST', json);
status.textContent = result.message || 'Avatar item uploaded successfully.';
} catch (error) {
const status = document.querySelector('#avatarStatus');
if (status) {
status.textContent = error.message;
status.classList.add('error');
}
}
});
}
async function preferencesView() {
app.innerHTML = `Preferences `;
try {
const prefs = await get(`${API}/admin/preferences`);
app.innerHTML = `
Preferences
Appearance
Theme
Dark
Light
Accent Color
Save Preferences
`;
const savePrefsBtn = document.querySelector('#savePrefs');
if (savePrefsBtn) savePrefsBtn.addEventListener('click', async () => {
const status = document.querySelector('#prefsStatus');
const themeSelect = document.querySelector('#themeSelect');
const accentColor = document.querySelector('#accentColor');
if (!status || !themeSelect || !accentColor) return;
status.textContent = 'Saving...';
status.classList.remove('error');
try {
await send(`${API}/admin/preferences`, 'PUT', {
theme: themeSelect.value,
accentColor: accentColor.value
});
status.textContent = 'Saved successfully.';
applyPreferences({
theme: themeSelect.value,
accentColor: accentColor.value
});
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
applyPreferences(prefs);
} catch (error) {
app.innerHTML = `Preferences `;
}
}
function applyPreferences(prefs) {
if (!prefs) return;
const root = document.documentElement;
if (prefs.theme === 'light') {
root.style.setProperty('--bg', '#f5f5f5');
root.style.setProperty('--panel', '#ffffff');
root.style.setProperty('--panel-alt', '#f0f0f0');
root.style.setProperty('--text', '#1a1a1a');
root.style.setProperty('--text-dim', '#666666');
root.style.setProperty('--border', '#e0e0e0');
} else {
root.style.removeProperty('--bg');
root.style.removeProperty('--panel');
root.style.removeProperty('--panel-alt');
root.style.removeProperty('--text');
root.style.removeProperty('--text-dim');
root.style.removeProperty('--border');
}
if (prefs.accentColor) {
root.style.setProperty('--primary', prefs.accentColor);
}
}
async function playerReportsView() {
app.innerHTML = `
Player Reports
Status
Pending
Banned
Timed Out
No Action
All
`;
const statusFilter = document.querySelector('#reportStatusFilter');
const list = document.querySelector('#playerReportList');
async function resolveReport(id, action) {
const body = { action };
if (action === 'noaction') {
body.reason = prompt('Optional note:') || '';
} else if (action === 'ban') {
const reason = prompt('Ban reason:');
if (!reason) return;
body.reason = reason;
const durationUnit = (prompt('Duration unit (seconds/minutes/hours/days/weeks/permanent):', 'permanent') || 'permanent').trim().toLowerCase();
body.durationUnit = durationUnit;
if (durationUnit !== 'permanent') {
body.durationAmount = Number(prompt('Duration amount:', '1') || '1');
}
} else {
const reason = prompt('Timeout reason:');
if (!reason) return;
body.reason = reason;
body.durationUnit = (prompt('Timeout unit (seconds/minutes/hours/days), capped at 30 days:', 'hours') || 'hours').trim().toLowerCase();
body.durationAmount = Number(prompt('Timeout amount:', '24') || '24');
}
try {
await send(`${API}/admin/reports/players/${encodeURIComponent(id)}/resolve`, 'POST', body);
await loadReports();
} catch (error) { alert(error.message); }
}
async function loadReports() {
list.innerHTML = ' Loading reports
';
try {
const reports = await get(`${API}/admin/reports/players?status=${encodeURIComponent(statusFilter.value)}`);
if (!reports.length) { list.innerHTML = 'No reports here.
'; return; }
list.innerHTML = reports.map(r => `
${esc(r.reportedUsername || (r.reportedPlayerId ? `Account #${r.reportedPlayerId}` : 'Unknown player'))}
${esc(r.status)}
Reported by ${esc(r.reporterUsername || `Account #${r.reporterId}`)} on ${new Date(r.createdAt).toLocaleString()}
${r.reportCategory != null ? `Category #${esc(r.reportCategory)} ` : ''}
${r.details ? `${esc(r.details)} ` : ''}
${r.roomId ? `Room ${esc(r.roomId)}${r.roomInstanceType ? ` (${esc(r.roomInstanceType)})` : ''} ` : ''}
${r.resolutionNote ? `Resolution: ${esc(r.resolutionNote)} ` : ''}
${r.status === 'Pending' ? `
Ban
Timeout
No Action
` : ''}
`).join('');
list.querySelectorAll('.act-ban').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'ban')));
list.querySelectorAll('.act-timeout').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'timeout')));
list.querySelectorAll('.act-noaction').forEach(btn => btn.addEventListener('click', () => resolveReport(btn.dataset.id, 'noaction')));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
statusFilter.addEventListener('change', loadReports);
await loadReports();
}
async function bugReportsView() {
app.innerHTML = `
Bug Reports
Status
Open
Closed
All
`;
const statusFilter = document.querySelector('#bugStatusFilter');
const list = document.querySelector('#bugReportList');
async function loadBugReports() {
list.innerHTML = ' Loading bug reports
';
try {
const reports = await get(`${API}/admin/reports/bugs?status=${encodeURIComponent(statusFilter.value)}`);
if (!reports.length) { list.innerHTML = 'No bug reports here.
'; return; }
list.innerHTML = reports.map(r => `
${esc(r.category || 'Uncategorized')}
${esc(r.status)}
From ${esc(r.reporterUsername || `Account #${r.reporterId}`)} on ${new Date(r.createdAt).toLocaleString()}
${esc(r.description || '')}
${r.status === 'Open' ? 'Close' : 'Reopen'}
`).join('');
list.querySelectorAll('.toggle-bug').forEach(btn => btn.addEventListener('click', async () => {
try {
await send(`${API}/admin/reports/bugs/${encodeURIComponent(btn.dataset.id)}/resolve`, 'POST', { status: btn.dataset.next });
await loadBugReports();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
statusFilter.addEventListener('change', loadBugReports);
await loadBugReports();
}
async function ipBansView() {
app.innerHTML = `
IP Bans
Add IP Ban
IP / CIDR
Reason
Add IP Ban
`;
const networkInput = document.querySelector('#ipBanNetwork');
const reasonInput = document.querySelector('#ipBanReason');
const status = document.querySelector('#ipBanStatus');
const list = document.querySelector('#ipBanList');
async function loadIpBans() {
list.innerHTML = ' Loading IP bans
';
try {
const bans = await get(`${API}/admin/ip-bans`);
if (!bans.length) { list.innerHTML = 'No IP addresses or ranges are banned.
'; return; }
list.innerHTML = bans.map(b => `
${esc(b.network)} ${esc(b.reason || 'Blocked by an administrator.')} Added ${new Date(b.createdAt).toLocaleString()} by account #${b.createdByAccountId || 0}
Remove
`).join('');
list.querySelectorAll('.remove-ip').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Remove this IP ban?')) return;
try {
await send(`${API}/admin/ip-bans/${encodeURIComponent(btn.dataset.id)}`, 'DELETE', {});
await loadIpBans();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
const addIpBanBtn = document.querySelector('#addIpBan');
if (addIpBanBtn) addIpBanBtn.addEventListener('click', async () => {
const network = networkInput.value.trim();
const reason = reasonInput.value.trim();
if (!network) return;
try {
status.textContent = 'Adding IP ban...';
status.classList.remove('error');
await send(`${API}/admin/ip-bans`, 'POST', { network, reason });
networkInput.value = '';
reasonInput.value = '';
status.textContent = 'IP ban added.';
await loadIpBans();
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
await loadIpBans();
}
async function steamBlacklistView() {
app.innerHTML = `
Steam Blacklist
`;
const steamInput = document.querySelector('#steamId');
const reasonInput = document.querySelector('#steamReason');
const status = document.querySelector('#steamStatus');
const list = document.querySelector('#steamList');
async function loadSteamBlacklist() {
list.innerHTML = ' Loading Steam blacklist
';
try {
const items = await get(`${API}/admin/steam-blacklist`);
if (!items.length) { list.innerHTML = 'No Steam IDs are blacklisted.
'; return; }
list.innerHTML = items.map(item => `
${esc(item.steamId)} ${esc(item.reason || 'Blacklisted by an administrator.')} Added by ${esc(item.addedByDisplayName || item.addedByByUsername || `Account #${item.addedByAccountId}`)} on ${new Date(item.addedAt || item.AddedAt).toLocaleString()}
Unblacklist
`).join('');
list.querySelectorAll('.remove-steam').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm(`Remove Steam ID ${btn.dataset.steam} from the blacklist?`)) return;
try {
await send(`${API}/admin/steam-blacklist/${encodeURIComponent(btn.dataset.steam)}`, 'DELETE', {});
await loadSteamBlacklist();
} catch (error) { alert(error.message); }
}));
} catch (error) {
list.innerHTML = `${esc(error.message)}
`;
}
}
const addSteamBtn = document.querySelector('#addSteam');
if (addSteamBtn) addSteamBtn.addEventListener('click', async () => {
const steamId = steamInput.value.trim();
const reason = reasonInput.value.trim();
if (!steamId) return;
if (!confirm(`Blacklist Steam ID ${steamId}? Future login and connect attempts will receive HTTP 403.`)) return;
try {
status.textContent = 'Saving...';
status.classList.remove('error');
await send(`${API}/admin/steam-blacklist`, 'POST', { steamId, reason });
steamInput.value = '';
reasonInput.value = '';
status.textContent = 'Steam ID blacklisted.';
await loadSteamBlacklist();
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
await loadSteamBlacklist();
}
async function shopView() {
app.innerHTML = `
Shop Controls
Shop Actions
Refresh Shop Now
`;
const status = document.querySelector('#shopStatus');
const customItems = document.querySelector('#customShopItems');
const searchInput = document.querySelector('#shopSearch');
const searchResults = document.querySelector('#shopSearchResults');
async function loadCustomItems() {
customItems.innerHTML = ' Loading custom items
';
try {
const items = await get(`${API}/admin/shop`);
if (!items.customItems || !items.customItems.length) { customItems.innerHTML = 'No custom items pinned. All 10 slots are random 5-stars.
'; return; }
customItems.innerHTML = items.customItems.map(item => `
${esc(item.friendlyName)} SKU ${item.skuId} · ${item.price} tokens
Remove
`).join('');
customItems.querySelectorAll('.remove-shop').forEach(btn => btn.addEventListener('click', async () => {
try {
const status = document.querySelector('#boardStatus');
status.textContent = 'Saving...';
status.classList.remove('error');
try {
await send(`${API}/admin/shop/items/${btn.dataset.sku}`, 'DELETE', {});
status.textContent = 'Saved successfully.';
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
} catch (error) {
alert(error.message);
}
}));
} catch (error) {
customItems.innerHTML = `${esc(error.message)}
`;
}
}
const refreshShopBtn = document.querySelector('#refreshShop');
if (refreshShopBtn) refreshShopBtn.addEventListener('click', async () => {
const status = document.querySelector('#shopStatus');
if (!status) return;
try {
status.textContent = 'Refreshing shop...';
status.classList.remove('error');
await send(`${API}/admin/shop/refresh`, 'POST', {});
status.textContent = 'Shop refreshed.';
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
let shopSearchSkip = 0;
const shopSearchTake = 20;
async function runShopSearch() {
const query = searchInput.value.trim();
const pager = document.querySelector('#shopSearchPager');
if (!query) { searchResults.innerHTML = ''; pager.style.display = 'none'; return; }
try {
const data = await get(`${API}/admin/shop/catalog?search=${encodeURIComponent(query)}&skip=${shopSearchSkip}&take=${shopSearchTake}`);
const items = data.results ?? data;
const total = data.total ?? items.length;
if (!items.length) { searchResults.innerHTML = 'No items found.
'; }
else {
searchResults.innerHTML = items.map(item => `
${esc(item.friendlyName)} SKU ${item.skuId} · Avatar #${item.avatarItemId || 'N/A'}
Add
`).join('');
searchResults.querySelectorAll('.add-shop').forEach(btn => btn.addEventListener('click', async (e) => {
e.stopPropagation();
const sku = btn.closest('[data-sku]').dataset.sku;
try {
await send(`${API}/admin/shop/custom-items`, 'POST', { skuId: Number(sku) });
await loadCustomItems();
} catch (error) { alert(error.message); }
}));
}
pager.style.display = total > shopSearchTake ? 'flex' : 'none';
document.querySelector('#shopSearchPageInfo').textContent = total ? `${shopSearchSkip + 1}-${Math.min(shopSearchSkip + shopSearchTake, total)} of ${total}` : '';
document.querySelector('#shopSearchPrev').disabled = shopSearchSkip <= 0;
document.querySelector('#shopSearchNext').disabled = shopSearchSkip + shopSearchTake >= total;
} catch (error) {
searchResults.innerHTML = `${esc(error.message)}
`;
}
}
let searchTimer;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { shopSearchSkip = 0; runShopSearch(); }, 300);
});
document.querySelector('#shopSearchPrev').addEventListener('click', () => { shopSearchSkip = Math.max(0, shopSearchSkip - shopSearchTake); runShopSearch(); });
document.querySelector('#shopSearchNext').addEventListener('click', () => { shopSearchSkip += shopSearchTake; runShopSearch(); });
await loadCustomItems();
}
function describeImportResult(result) {
let text = `Imported! Room ID: ${result.roomId}, Subrooms: ${result.subRoomsImported}, Saves: ${result.savesImported}`;
if (result.bakedAssetsImported) text += `, Baked assets: ${result.bakedAssetsImported}`;
if (result.assetBundlesCopied) text += ` (${result.assetBundlesCopied} bundles copied${result.assetBundlesMissing ? `, ${result.assetBundlesMissing} missing` : ''})`;
if (result.unityEngineVersions?.length) text += `. Unity versions: ${result.unityEngineVersions.join(', ')}`;
return text;
}
async function pollMassImportJob(jobId, status, progressBox) {
while (true) {
let job;
try {
job = await get(`${API}/admin/rooms/import-batch/${jobId}`);
} catch (error) {
status.textContent = `Lost track of the import job: ${error.message}`;
status.classList.add('error');
return;
}
const total = job.totalFound || 0;
const done = job.completedCount || 0;
if (job.status === 'pending') {
status.textContent = 'Finding rooms...';
} else if (job.status === 'running') {
status.textContent = `Importing ${done}/${total}${job.currentRoomName ? ` - ${job.currentRoomName}` : ''}...`;
} else if (job.status === 'failed') {
status.textContent = `Batch failed: ${job.fatalError || 'unknown error'}`;
status.classList.add('error');
} else {
status.textContent = `Done: ${job.successCount}/${total} imported${job.failedCount ? `, ${job.failedCount} failed` : ''}.`;
}
if (progressBox) {
progressBox.innerHTML = (job.results || []).map(r => {
const detail = r.success
? ` (room ${r.roomId}, ${r.subRoomsImported} subroom${r.subRoomsImported === 1 ? '' : 's'}${r.bakedAssetsImported ? `, ${r.bakedAssetsImported} baked assets` : ''})`
: ` — ${r.error}`;
const color = r.success ? '#4caf50' : '#ff5555';
return `${r.success ? '✓' : '✗'} ${r.name}${detail}
`;
}).join('');
}
if (job.status === 'completed' || job.status === 'failed') return;
await new Promise(resolve => setTimeout(resolve, 1500));
}
}
async function roomImporterView() {
app.innerHTML = `
Room Importer
`;
const importMethod = document.querySelector('#importMethod');
const zipSection = document.querySelector('#zipSection');
const urlSection = document.querySelector('#urlSection');
const jsonSection = document.querySelector('#jsonSection');
const massToggle = document.querySelector('#massImportToggle');
const massHint = document.querySelector('#massImportHint');
const importBtnLabel = document.querySelector('#importRoom');
function applyMassLabels() {
const isMass = massToggle && massToggle.checked;
massHint.style.display = isMass ? '' : 'none';
document.querySelector('#roomZipLabel').firstChild.textContent = isMass
? 'Rooms Export ZIP (multiple room folders)'
: 'Room Export ZIP (.zip)';
document.querySelector('#roomUrlLabel').firstChild.textContent = isMass
? 'Rooms index URL'
: 'Room folder URL';
document.querySelector('#roomUrl').placeholder = isMass
? 'https://archive.splootybean.com/Rooms/'
: 'https://archive.splootybean.com/Rooms/Showdown/';
document.querySelector('#roomUrlHint').textContent = isMass
? 'Point this at the index folder that lists every room\'s own folder (e.g. .../Rooms/) - each room folder found directly under it gets crawled and imported in turn. Same per-room 1 GiB cap, plus a 300 room-folder cap on the listing itself.'
: 'Point this at the page that directly lists room.json and the numbered subroom folders - not the parent Rooms/ index. The server crawls that listing itself: room.json, every subroom\'s saves, and only the specific baked asset bundles this room\'s save data actually references (not the whole shared AssetBundles/ folder). Capped at 1 GiB total.';
document.querySelector('#roomJsonLabel').firstChild.textContent = isMass
? 'JSON array of room metadata objects'
: 'Unity Scene JSON Metadata';
document.querySelector('#roomJson').placeholder = isMass
? 'Paste a JSON array here: [{...room 1...}, {...room 2...}]'
: 'Paste Unity scene JSON metadata here...';
if (importBtnLabel) importBtnLabel.textContent = isMass ? 'Import Rooms' : 'Import Room';
}
if (massToggle) massToggle.addEventListener('change', applyMassLabels);
applyMassLabels();
if (importMethod) importMethod.addEventListener('change', () => {
zipSection.style.display = importMethod.value === 'zip' ? '' : 'none';
urlSection.style.display = importMethod.value === 'url' ? '' : 'none';
jsonSection.style.display = importMethod.value === 'json' ? '' : 'none';
});
const importBtn = document.querySelector('#importRoom');
if (importBtn) importBtn.addEventListener('click', async () => {
const status = document.querySelector('#importStatus');
const progressBox = document.querySelector('#massProgress');
if (!status) return;
status.textContent = 'Importing...';
status.classList.remove('error');
if (progressBox) progressBox.innerHTML = '';
const isMass = massToggle && massToggle.checked;
try {
if (isMass) {
let startResult;
if (importMethod.value === 'json') {
const roomJson = document.querySelector('#roomJson');
if (!roomJson || !roomJson.value.trim()) {
status.textContent = 'Please paste a JSON array of room metadata objects.';
status.classList.add('error');
return;
}
startResult = await send(`${API}/admin/rooms/import-batch/json`, 'POST', roomJson.value.trim());
} else if (importMethod.value === 'url') {
const roomUrl = document.querySelector('#roomUrl');
const urlCreatorAccountId = document.querySelector('#urlCreatorAccountId');
const urlReplaceExisting = document.querySelector('#urlReplaceExisting');
if (!roomUrl || !roomUrl.value.trim()) {
status.textContent = 'Please enter the rooms index URL.';
status.classList.add('error');
return;
}
status.textContent = 'Finding room folders...';
startResult = await send(`${API}/admin/rooms/import-batch/url`, 'POST', {
url: roomUrl.value.trim(),
creatorAccountId: urlCreatorAccountId?.value ? Number(urlCreatorAccountId.value) : null,
replaceExisting: urlReplaceExisting ? urlReplaceExisting.checked : true
});
} else {
const roomZip = document.querySelector('#roomZip');
const creatorAccountId = document.querySelector('#creatorAccountId');
const replaceExisting = document.querySelector('#replaceExisting');
if (!roomZip || !roomZip.files || !roomZip.files[0]) {
status.textContent = 'Please select a ZIP file.';
status.classList.add('error');
return;
}
const formData = new FormData();
formData.append('file', roomZip.files[0]);
if (creatorAccountId && creatorAccountId.value) {
formData.append('creatorAccountId', creatorAccountId.value);
}
if (replaceExisting) {
formData.append('replaceExisting', replaceExisting.checked.toString());
}
status.textContent = 'Uploading...';
const response = await fetch(`${API}/admin/rooms/import-batch/zip`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `Request failed (${response.status})`);
}
startResult = await response.json();
}
await pollMassImportJob(startResult.jobId, status, progressBox);
return;
}
if (importMethod.value === 'json') {
const roomJson = document.querySelector('#roomJson');
if (!roomJson || !roomJson.value.trim()) {
status.textContent = 'Please paste JSON metadata.';
status.classList.add('error');
return;
}
const result = await send(`${API}/admin/rooms/import`, 'POST', roomJson.value.trim());
status.textContent = describeImportResult(result);
} else if (importMethod.value === 'url') {
const roomUrl = document.querySelector('#roomUrl');
const urlCreatorAccountId = document.querySelector('#urlCreatorAccountId');
const urlReplaceExisting = document.querySelector('#urlReplaceExisting');
if (!roomUrl || !roomUrl.value.trim()) {
status.textContent = 'Please enter the room folder URL.';
status.classList.add('error');
return;
}
status.textContent = 'Starting crawl...';
const startResult = await send(`${API}/admin/rooms/import-url`, 'POST', {
url: roomUrl.value.trim(),
creatorAccountId: urlCreatorAccountId?.value ? Number(urlCreatorAccountId.value) : null,
replaceExisting: urlReplaceExisting ? urlReplaceExisting.checked : true
});
await pollMassImportJob(startResult.jobId, status, progressBox);
return;
} else {
const roomZip = document.querySelector('#roomZip');
const creatorAccountId = document.querySelector('#creatorAccountId');
const replaceExisting = document.querySelector('#replaceExisting');
if (!roomZip || !roomZip.files || !roomZip.files[0]) {
status.textContent = 'Please select a ZIP file.';
status.classList.add('error');
return;
}
const formData = new FormData();
formData.append('file', roomZip.files[0]);
if (creatorAccountId && creatorAccountId.value) {
formData.append('creatorAccountId', creatorAccountId.value);
}
if (replaceExisting) {
formData.append('replaceExisting', replaceExisting.checked.toString());
}
status.textContent = 'Uploading...';
const response = await fetch(`${API}/admin/rooms/import`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `Request failed (${response.status})`);
}
const startResult = await response.json();
await pollMassImportJob(startResult.jobId, status, progressBox);
return;
}
status.classList.remove('error');
} catch (error) {
status.textContent = error.message;
status.classList.add('error');
}
});
}
async function logsView() {
app.innerHTML = `
Server Logs
`;
const output = document.querySelector('#logsOutput');
const takeInput = document.querySelector('#logsTake');
const autoRefresh = document.querySelector('#logsAutoRefresh');
let timer = null;
async function load() {
const take = Math.max(50, Math.min(5000, Number(takeInput.value) || 500));
try {
const result = await get(`${API}/admin/logs?take=${take}`);
output.textContent = (result.lines || []).join('\n') || '(no log lines yet)';
output.scrollTop = output.scrollHeight;
} catch (error) {
output.textContent = `Error loading logs: ${error.message}`;
}
}
document.querySelector('#logsRefresh').addEventListener('click', load);
autoRefresh.addEventListener('change', () => {
if (timer) { clearInterval(timer); timer = null; }
if (autoRefresh.checked) timer = setInterval(load, 5000);
});
await load();
}
async function scannerLogsView() {
app.innerHTML = `
Loser Scanner Logs
Every request that hit a path nothing on this server recognizes - nobody legitimate does that by hand, so this is scanner/bot traffic going fishing for stuff like .env, aws config files, wp-login.php, .git/config, and the like. Highlighted rows matched one of those patterns specifically; everything else just hit a URL that doesn't exist here.
Refresh
Load more
`;
const resultsEl = document.querySelector('#scannerResults');
const totalEl = document.querySelector('#scannerTotal');
const ipFilter = document.querySelector('#scannerIpFilter');
const loadMoreBtn = document.querySelector('#scannerLoadMore');
const pageSize = 100;
let skip = 0;
let rows = [];
function renderRows() {
if (!rows.length) {
resultsEl.innerHTML = 'No scanner attempts logged yet.
';
return;
}
resultsEl.innerHTML = rows.map(r => `
${esc(new Date(r.timestamp).toLocaleString())} · ${esc(r.ipAddress)} · ${esc(r.method)} ${esc(r.path)}${r.queryString ? esc(r.queryString) : ''}
${r.matchedPattern ? `
matched: ${esc(r.matchedPattern)}
` : ''}
${r.userAgent ? `
${esc(r.userAgent)}
` : ''}
`).join('');
}
async function load(reset) {
if (reset) { skip = 0; rows = []; }
try {
const ip = ipFilter.value.trim();
const result = await get(`${API}/admin/scanner-logs?skip=${skip}&take=${pageSize}${ip ? `&ip=${encodeURIComponent(ip)}` : ''}`);
rows = rows.concat(result.results || []);
skip = rows.length;
totalEl.textContent = `${result.total} total attempt${result.total === 1 ? '' : 's'} logged`;
loadMoreBtn.style.display = (result.results || []).length === pageSize ? '' : 'none';
renderRows();
} catch (error) {
resultsEl.innerHTML = `${esc(error.message)}
`;
}
}
document.querySelector('#scannerRefresh').addEventListener('click', () => load(true));
ipFilter.addEventListener('keydown', e => { if (e.key === 'Enter') load(true); });
loadMoreBtn.addEventListener('click', () => load(false));
await load(true);
}
async function anticheatLogsView() {
app.innerHTML = `
Anticheat Logs
Every time the client mod caught something bad on its own machine - a debugger attached, a proxy sitting in the traffic path, a known injector process or module, or an unrecognized MelonLoader mod riding along - it reported here and force-closed itself.
Refresh
Load more
`;
const resultsEl = document.querySelector('#acResults');
const totalEl = document.querySelector('#acTotal');
const ipFilter = document.querySelector('#acIpFilter');
const loadMoreBtn = document.querySelector('#acLoadMore');
const pageSize = 100;
let skip = 0;
let rows = [];
function renderRows() {
if (!rows.length) {
resultsEl.innerHTML = 'No anticheat flags logged yet.
';
return;
}
resultsEl.innerHTML = rows.map(r => `
${esc(new Date(r.timestamp).toLocaleString())} ·
${esc(r.ipAddress)} ${r.accountId ? ` ·
account ${r.accountId} ` : ''} · steam ${esc(r.steamId || '0')} · build ${esc(r.build || '?')}
${esc(r.flags)}
${r.userAgent ? `
${esc(r.userAgent)}
` : ''}
`).join('');
}
async function load(reset) {
if (reset) { skip = 0; rows = []; }
try {
const ip = ipFilter.value.trim();
const result = await get(`${API}/admin/anticheat-logs?skip=${skip}&take=${pageSize}${ip ? `&ip=${encodeURIComponent(ip)}` : ''}`);
rows = rows.concat(result.results || []);
skip = rows.length;
totalEl.textContent = `${result.total} total flag${result.total === 1 ? '' : 's'} logged`;
loadMoreBtn.style.display = (result.results || []).length === pageSize ? '' : 'none';
renderRows();
} catch (error) {
resultsEl.innerHTML = `${esc(error.message)}
`;
}
}
document.querySelector('#acRefresh').addEventListener('click', () => load(true));
ipFilter.addEventListener('keydown', e => { if (e.key === 'Enter') load(true); });
loadMoreBtn.addEventListener('click', () => load(false));
await load(true);
}
async function boot() {
try {
currentUser = await get(`${API}/auth/me`);
} catch {
currentUser = null;
}
if (!currentUser || (!currentUser.isModerator && !currentUser.isDeveloper && !currentUser.isAdmin)) {
renderLoginGate();
return;
}
applyRoleGating();
try {
const prefs = await get(`${API}/admin/preferences`);
if (prefs) applyPreferences(prefs);
} catch (e) {
}
await route();
setInterval(checkSystemStatus, 10000);
}
async function checkSystemStatus() {
try {
const status = await get(`${API}/admin/system-status`);
const banner = document.querySelector('#ddosBanner');
if (status.isUnderAttack) {
if (!banner) {
const bannerEl = document.createElement('div');
bannerEl.id = 'ddosBanner';
bannerEl.style.cssText = 'position:fixed;top:60px;left:0;right:0;background:#ff4444;color:white;padding:12px;text-align:center;font-weight:bold;z-index:9999;animation:pulse 1s infinite';
bannerEl.innerHTML = '⚠️ WE\'RE GETTING DDOSED :c ⚠️';
document.body.appendChild(bannerEl);
}
} else if (banner) {
banner.remove();
}
} catch {}
}
boot();