3283 lines
187 KiB
JavaScript
3283 lines
187 KiB
JavaScript
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 = `<div class="login-gate"><i class="fa-solid fa-lock" style="font-size:26px;color:var(--text-dim)"></i><h1 class="page-title">Staff sign-in required</h1><p class="field-hint">Log in with a Moderator or Developer account through the main site, then come back here.</p><a class="btn" href="/recnet/">Go to main site</a></div>`;
|
|
}
|
|
|
|
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 = `<div class="placeholder-card"><i class="fa-solid fa-lock"></i><h1 class="page-title">${esc(title)} needs Developer access</h1><p>This section's actions all require the Developer role on the server, not just Moderator.</p></div>`;
|
|
}
|
|
|
|
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 = `<div class="card"><p class="form-status error">${esc(error.message)}</p></div>`;
|
|
}
|
|
}
|
|
addEventListener('hashchange', route);
|
|
|
|
function placeholder(view) {
|
|
const title = document.querySelector(`.nav-link[data-view="${view}"]`)?.textContent.replace(' · soon', '') || 'This section';
|
|
app.innerHTML = `<div class="placeholder-card"><i class="fa-solid fa-hammer"></i><h1 class="page-title">${esc(title)} isn't built here yet</h1><p>This part of the new panel doesn't exist yet - it still works fine in the original admin panel for now.</p></div>`;
|
|
}
|
|
|
|
async function playersList() {
|
|
app.innerHTML = `<h1 class="page-title">Players</h1><div id="playerOverview" class="stat-grid"><div class="loading"><i></i>Loading overview</div></div><div class="search-row"><input id="playerSearch" placeholder="Search by username, display name, or ID"><button class="btn" id="playerSearchGo">Search</button></div><div id="playerListResults" class="player-list"></div>`;
|
|
(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]) => `<div class="stat-pill"><strong>${value}</strong><span>${esc(label)}</span></div>`).join('');
|
|
} catch (error) {
|
|
overview.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
})();
|
|
const input = document.querySelector('#playerSearch'), results = document.querySelector('#playerListResults');
|
|
async function runSearch() {
|
|
results.innerHTML = '<div class="loading"><i></i>Loading players</div>';
|
|
try {
|
|
const players = await get(`${API}/admin/accounts?search=${encodeURIComponent(input.value.trim())}`);
|
|
if (!players.length) { results.innerHTML = '<p class="field-hint">No players found.</p>'; return; }
|
|
results.innerHTML = players.map(p => `<a class="player-row" href="#players/${p.accountId}"><img src="${esc(p.profileImage || '')}" class="avatar-img" alt=""><div class="meta"><div class="name">${esc(p.displayName || p.username)}</div><div class="sub">@${esc(p.username)} · ID ${p.accountId} · Level ${p.level}${p.ban ? ' · <span class="badge banned">Banned</span>' : ''}</div></div></a>`).join('');
|
|
} catch (error) {
|
|
results.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 = '<div class="loading"><i></i>Loading player</div>';
|
|
let player;
|
|
try { player = await get(`${API}/admin/accounts/${accountId}`); }
|
|
catch (error) { app.innerHTML = `<a class="back-link" href="#players">← Back to Players</a><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`; return; }
|
|
|
|
const joined = player.createdAt ? new Date(player.createdAt).toLocaleDateString() : 'Unknown';
|
|
const badges = [...player.roles.map(r => `<span class="badge ${ROLE_BADGE_CLASS[r] || ''}">${esc(r)}</span>`)];
|
|
if (player.isJunior) badges.push('<span class="badge">Junior</span>');
|
|
if (player.ban) badges.push('<span class="badge banned">Banned</span>');
|
|
|
|
app.innerHTML = `
|
|
<a class="back-link" href="#players">← Back to Players</a>
|
|
<div class="profile-header">
|
|
<img src="${esc(player.profileImage || '')}" class="avatar-img" alt="">
|
|
<div>
|
|
<div class="name">@${esc(player.username)}</div>
|
|
<div class="username-sub">${esc(player.displayName || '')}</div>
|
|
<div class="stat-line">Level: ${player.level}</div>
|
|
<div class="stat-line">Bio: ${esc(player.bio || '(none)')}</div>
|
|
<div class="stat-line">ID: ${player.accountId} · Joined ${joined}</div>
|
|
<div class="profile-actions">
|
|
<button class="btn" data-act="message">Send Coach Message</button>
|
|
<button class="btn" data-act="force-join">Force Join</button>
|
|
<button class="btn ghost" data-act="edit-profile">Edit Profile</button>
|
|
<button class="btn ghost" data-act="reset-username">Reset Username</button>
|
|
<button class="btn ghost" data-act="reset-pfp">Reset PFP</button>
|
|
<button class="btn ghost" data-act="reset-banner">Reset Banner</button>
|
|
<button class="btn ghost" data-act="reset-password">Reset Password</button>
|
|
<button class="btn ghost" data-act="copy-id">Copy ID</button>
|
|
<button class="btn danger" data-act="ban">${player.ban ? 'Unban' : 'Ban'}</button>
|
|
<button class="btn danger" data-act="mod-lock">${player.moderationLock ? 'Remove Moderation Lock' : 'Apply Moderation Lock'}</button>
|
|
<button class="btn danger" data-act="delete-account">Delete Account</button>
|
|
</div>
|
|
<div class="profile-actions" style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border)">
|
|
<select id="roleSelect" style="width:auto">
|
|
<option value="Moderator">Moderator</option>
|
|
<option value="Developer">Developer</option>
|
|
<option value="RRPlus">RRPlus</option>
|
|
<option value="Verified">Verified</option>
|
|
</select>
|
|
<button class="btn ghost" data-act="add-role">Add Role</button>
|
|
<button class="btn ghost" data-act="clear-roles">Clear Roles</button>
|
|
<button class="btn ghost" data-act="force-into-arbitrary">Force Into Another Player's Instance</button>
|
|
</div>
|
|
<div class="profile-badges">${badges.join('') || '<span class="field-hint">No roles</span>'}${player.moderationLock ? ' <span class="badge banned">Mod Locked</span>' : ''}</div>
|
|
</div>
|
|
</div>
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="settings">Settings</div>
|
|
<div class="tab" data-tab="relationships">Relationships</div>
|
|
<div class="tab" data-tab="threads">Threads</div>
|
|
<div class="tab" data-tab="inventory">Inventory</div>
|
|
<div class="tab" data-tab="photos-mine">My Photos</div>
|
|
<div class="tab" data-tab="photos-of-me">Photos of Me</div>
|
|
<div class="tab" data-tab="gifts">Gifts</div>
|
|
<div class="tab" data-tab="reputation">Reputation</div>
|
|
<div class="tab" data-tab="progression">Progression</div>
|
|
<div class="tab dev-only" data-tab="troll">Troll</div>
|
|
</div>
|
|
<div id="tabBody"></div>`;
|
|
|
|
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 = `<h2 class="page-title" style="font-size:16px">Settings</h2>
|
|
<div class="card">
|
|
<h2>Token Balance</h2>
|
|
<p class="field-hint" id="balanceCurrent">Loading...</p>
|
|
<div class="field-row">
|
|
<label class="field">Amount<input type="number" id="balanceAmount" min="0" value="0"></label>
|
|
<label class="field">Mode<select id="balanceMode"><option value="set">Set to</option><option value="add">Add</option></select></label>
|
|
</div>
|
|
<button class="btn" id="applyBalance">Apply</button>
|
|
<span class="form-status" id="balanceStatus"></span>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Linked Platforms</h2>
|
|
<div id="platformList"><div class="loading"><i></i>Loading platforms</div></div>
|
|
<div class="field-row" style="margin-top:10px">
|
|
<label class="field">Platform<select id="newPlatform"><option>Steam</option><option>Oculus</option><option>PlayStation</option><option>Xbox</option><option>IOS</option><option>GooglePlay</option></select></label>
|
|
<label class="field">Platform ID<input id="newPlatformId" inputmode="numeric" placeholder="Numeric ID"></label>
|
|
</div>
|
|
<button class="btn" id="addPlatform">Link Platform</button>
|
|
<span class="form-status" id="platformStatus"></span>
|
|
</div>
|
|
<h2 class="page-title" style="font-size:16px">Custom Settings</h2>
|
|
<div class="settings-add-row"><input id="newSettingKey" placeholder="New key"><input id="newSettingValue" placeholder="Value"><button class="btn" id="addSetting">Add / set</button></div><div id="settingsTable"><div class="loading"><i></i>Loading settings</div></div>`;
|
|
|
|
(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 = '<p class="field-hint">No linked platforms.</p>'; return; }
|
|
list.innerHTML = platforms.map(p => `<div class="room-row"><div class="meta"><strong>${esc(p.platform)}</strong><br><small>${esc(p.platformId)}</small></div><button class="btn small danger unlink-platform" data-platform="${esc(p.platform)}" data-platform-id="${esc(p.platformId)}">Unlink</button></div>`).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 = '<p class="field-hint">No settings stored.</p>'; return; }
|
|
table.innerHTML = `<table><thead><tr><th>Key</th><th>Value</th><th>Actions</th></tr></thead><tbody>${settings.map(s => `<tr data-key="${esc(s.key)}"><td data-label="Key">${esc(s.key)}</td><td data-label="Value"><input class="setting-value" value="${esc(s.value)}"></td><td data-label="Actions" class="actions-cell"><button class="btn small save-setting">Save</button><button class="btn small danger delete-setting">Delete</button></td></tr>`).join('')}</tbody></table>`;
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 = `<h2 class="page-title" style="font-size:16px">Progression & Account State</h2><div class="card"><h2>Update Progression</h2><div class="field-row"><label class="field">Level (1-50)<input type="number" id="progLevel" min="1" max="50"></label><label class="field">XP<input type="number" id="progXP" min="0"></label></div><button class="btn" id="updateProgression">Update</button><span class="form-status" id="progStatus"></span></div>
|
|
<div class="card"><h2>Account state & appearance</h2>
|
|
<div class="field-row">
|
|
<label class="field">Available username changes<input type="number" id="stateUsernameChanges" min="0"></label>
|
|
<label class="field">Personal pronouns (bitmask 0-63)<input type="number" id="statePronouns" min="0" max="63"></label>
|
|
</div>
|
|
<label class="field">Display emoji<input id="stateEmoji" maxlength="16"></label>
|
|
<label class="field">Profile image path<input id="stateProfileImage"></label>
|
|
<label class="field">Banner image path<input id="stateBannerImage"></label>
|
|
<label class="field" style="display:flex;align-items:center;gap:6px;flex-direction:row"><input type="checkbox" id="stateIsJunior" style="width:auto"> Junior account</label>
|
|
<button class="btn" id="updateAccountState">Save account state</button>
|
|
<span class="form-status" id="stateStatus"></span>
|
|
</div>`;
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
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 = `<h2 class="page-title" style="font-size:16px">Relationships</h2>
|
|
<div class="card dev-block">
|
|
<h2>Force add friend</h2>
|
|
<div class="field-row"><label class="field">Account ID<input type="number" id="forceAddFriendId" min="1"></label></div>
|
|
<button class="btn" id="forceAddFriend">Add friendship</button>
|
|
<span class="form-status" id="forceAddFriendStatus"></span>
|
|
</div>
|
|
<div id="relationshipsList"><div class="loading"><i></i>Loading relationships</div></div>`;
|
|
applyRoleGating();
|
|
|
|
async function loadRelationships() {
|
|
const list = document.querySelector('#relationshipsList');
|
|
list.innerHTML = '<div class="loading"><i></i>Loading relationships</div>';
|
|
try {
|
|
const relationships = await get(`${API}/admin/accounts/${accountId}/relationships`);
|
|
if (!relationships.length) { list.innerHTML = '<p class="field-hint">No relationships found.</p>'; return; }
|
|
const isDev = currentUser?.isDeveloper === true;
|
|
list.innerHTML = `<table><thead><tr><th>Player ID</th><th>Username</th><th>Display Name</th><th>Type</th><th>Flags</th>${isDev ? '<th>Actions</th>' : ''}</tr></thead><tbody>${relationships.map(r => `
|
|
<tr><td>${r.playerId}</td><td>${esc(r.username || 'N/A')}</td><td>${esc(r.displayName || 'N/A')}</td><td>${esc(r.relationshipType)}</td><td>${r.favorited ? '⭐ ' : ''}${r.muted ? '🔇 ' : ''}${r.ignored ? '🚫' : ''}</td>${isDev ? `<td><button class="btn small danger remove-relationship" data-id="${r.playerId}">Unadd</button></td>` : ''}</tr>
|
|
`).join('')}</tbody></table>`;
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = `<h2 class="page-title" style="font-size:16px">Threads</h2><div id="threadsList"><div class="loading"><i></i>Loading threads</div></div><div id="threadMessages"></div>`;
|
|
const list = document.querySelector('#threadsList');
|
|
const messagesBox = document.querySelector('#threadMessages');
|
|
|
|
async function openThread(threadId, name) {
|
|
messagesBox.innerHTML = `<div class="card"><h2>${esc(name)}</h2><div class="loading"><i></i>Loading messages</div></div>`;
|
|
try {
|
|
const messages = await get(`${API}/admin/accounts/${accountId}/chat/threads/${threadId}/messages`);
|
|
messagesBox.innerHTML = `<div class="card"><h2>${esc(name)}</h2>${messages.length ? messages.map(m => `<div class="room-row"><div class="meta"><strong>${esc(m.senderDisplayName)}</strong><br>${esc(m.body)}<br><small class="field-hint">${new Date(m.createdAt).toLocaleString()}</small></div></div>`).join('') : '<p class="field-hint">No messages.</p>'}</div>`;
|
|
} catch (error) {
|
|
messagesBox.innerHTML = `<div class="card"><p class="form-status error">${esc(error.message)}</p></div>`;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const threads = await get(`${API}/admin/accounts/${accountId}/chat/threads`);
|
|
if (!threads.length) { list.innerHTML = '<p class="field-hint">No message threads.</p>'; return; }
|
|
list.innerHTML = threads.map(t => {
|
|
const name = t.name || t.members.map(m => m.displayName).join(', ') || `Thread ${t.threadId}`;
|
|
return `<div class="room-row clickable" data-id="${t.threadId}" data-name="${esc(name)}"><div class="meta"><strong>${esc(name)}</strong><br><small class="field-hint">${t.lastMessage ? esc(t.lastMessage.body) : 'No messages yet'}</small></div></div>`;
|
|
}).join('');
|
|
list.querySelectorAll('.room-row').forEach(row => row.addEventListener('click', () => openThread(row.dataset.id, row.dataset.name)));
|
|
} catch (error) {
|
|
list.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
} else if (tab === 'inventory') {
|
|
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Inventory</h2><div class="card"><input id="inventorySearch" placeholder="Search avatar items, equipment, consumables"><button class="btn" id="searchInventory">Search</button></div><div id="inventoryResults"><div class="loading"><i></i>Loading inventory</div></div>`;
|
|
const searchInput = document.querySelector('#inventorySearch');
|
|
const results = document.querySelector('#inventoryResults');
|
|
|
|
async function loadInventory() {
|
|
const search = searchInput.value.trim();
|
|
results.innerHTML = '<div class="loading"><i></i>Loading inventory</div>';
|
|
try {
|
|
const data = await get(`${API}/admin/accounts/${accountId}/inventory?search=${encodeURIComponent(search)}`);
|
|
const sections = [];
|
|
|
|
if (data.avatarItems && data.avatarItems.length) {
|
|
sections.push(`<h3>Avatar Items</h3><div class="inventory-grid">${data.avatarItems.map(item => `
|
|
<div class="inventory-item" data-type="avatar" data-id="${item.avatarItemId}" data-desc="${esc(item.avatarItemDesc)}">
|
|
<strong>${esc(item.friendlyName)}</strong><br>
|
|
<small>ID: ${item.avatarItemId}</small><br>
|
|
<label><input type="checkbox" ${item.owned ? 'checked' : ''} class="inventory-check"> Owned</label>
|
|
</div>
|
|
`).join('')}</div>`);
|
|
}
|
|
|
|
if (data.consumables && data.consumables.length) {
|
|
sections.push(`<h3>Consumables</h3><div class="inventory-grid">${data.consumables.map(item => `
|
|
<div class="inventory-item" data-type="consumable" data-id="${item.consumableItemId}" data-desc="${esc(item.consumableItemDesc)}">
|
|
<strong>${esc(item.friendlyName)}</strong><br>
|
|
<small>ID: ${item.consumableItemId}</small><br>
|
|
<label>Quantity: <input type="number" class="inventory-quantity" value="${item.quantity}" min="0" max="100000" style="width:60px"></label>
|
|
</div>
|
|
`).join('')}</div>`);
|
|
}
|
|
|
|
if (!sections.length) { results.innerHTML = '<p class="field-hint">No items found.</p>'; return; }
|
|
results.innerHTML = sections.join('<br><br>');
|
|
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = `<h2 class="page-title" style="font-size:16px">Reputation / Cheer Badges</h2><div id="reputationContent"><div class="loading"><i></i>Loading reputation</div></div>`;
|
|
const content = document.querySelector('#reputationContent');
|
|
|
|
async function loadReputation() {
|
|
content.innerHTML = '<div class="loading"><i></i>Loading reputation</div>';
|
|
try {
|
|
const data = await get(`${API}/admin/accounts/${accountId}/reputation`);
|
|
content.innerHTML = `
|
|
<div class="card">
|
|
<label><input type="checkbox" id="isCheerful" ${data.isCheerful ? 'checked' : ''}> Is Cheerful</label><br><br>
|
|
<label>Selected Badge<select id="selectedCheer">
|
|
${data.availableBadges.map(b => `<option value="${b}" ${data.selectedCheer === b ? 'selected' : ''}>${b}</option>`).join('')}
|
|
</select></label><br><br>
|
|
<h3>Cheer Counts</h3>
|
|
<label>General: <input type="number" id="cheerGeneral" value="${data.cheerGeneral}" min="0"></label><br>
|
|
<label>Helpful: <input type="number" id="cheerHelpful" value="${data.cheerHelpful}" min="0"></label><br>
|
|
<label>Sportsmanship: <input type="number" id="cheerSportsman" value="${data.cheerSportsman}" min="0"></label><br>
|
|
<label>Great Host: <input type="number" id="cheerGreatHost" value="${data.cheerGreatHost}" min="0"></label><br>
|
|
<label>Creative: <input type="number" id="cheerCreative" value="${data.cheerCreative}" min="0"></label><br><br>
|
|
<button class="btn" id="saveReputation">Save Reputation</button>
|
|
<span class="form-status" id="reputationStatus"></span>
|
|
</div>`;
|
|
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
await loadReputation();
|
|
} else if (tab === 'photos-mine' || tab === 'photos-of-me') {
|
|
const type = tab === 'photos-mine' ? 'mine' : 'of-me';
|
|
body.innerHTML = `<h2 class="page-title" style="font-size:16px">${tab === 'photos-mine' ? 'My Photos' : 'Photos of Me'}</h2><div id="photosContent"><div class="loading"><i></i>Loading photos</div></div>`;
|
|
const content = document.querySelector('#photosContent');
|
|
|
|
async function loadPhotos() {
|
|
content.innerHTML = '<div class="loading"><i></i>Loading photos</div>';
|
|
try {
|
|
const data = await get(`${API}/admin/accounts/${accountId}/photos?type=${type}`);
|
|
if (!data.photos.length) { content.innerHTML = '<p class="field-hint">No photos found.</p>'; return; }
|
|
content.innerHTML = `<p class="field-hint">${data.photoCount} photo(s)</p><div class="inventory-grid">${data.photos.map(p => `
|
|
<div class="inventory-item">
|
|
<img src="${esc(p.url)}" style="width:100%;height:150px;object-fit:cover;border-radius:4px;margin-bottom:5px" alt="">
|
|
<small>${esc(p.path)}</small><br>
|
|
<small>${new Date(p.takenAt).toLocaleString()}</small>
|
|
</div>
|
|
`).join('')}</div>`;
|
|
} catch (error) {
|
|
content.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
await loadPhotos();
|
|
} else if (tab === 'gifts') {
|
|
const isDev = currentUser?.isDeveloper === true;
|
|
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Pending Gifts</h2>${isDev ? '<button class="btn danger small" id="clearAllGifts">Clear All</button><span class="form-status" id="giftsStatus"></span>' : ''}<div id="giftsList" style="margin-top:10px"><div class="loading"><i></i>Loading pending gifts</div></div>`;
|
|
const list = document.querySelector('#giftsList');
|
|
|
|
async function loadGifts() {
|
|
list.innerHTML = '<div class="loading"><i></i>Loading pending gifts</div>';
|
|
try {
|
|
const gifts = await get(`${API}/admin/accounts/${accountId}/gifts`);
|
|
list.innerHTML = gifts.length ? gifts.map(g => `<div class="room-row" data-id="${g.giftPackageId}">
|
|
${g.thumbnailImage ? `<img src="${esc(g.thumbnailImage)}" alt="">` : ''}
|
|
<div class="meta">
|
|
<strong>${esc(g.friendlyName || (g.currency ? `${g.currency} tokens` : g.xp ? `${g.xp} XP` : 'Gift'))}</strong>
|
|
<br><small>From ${esc(g.fromDisplayName)} · GiftContext ${g.giftContext}${g.consumableQuantity > 1 ? ` · x${g.consumableQuantity}` : ''}</small>
|
|
${g.message ? `<br><small class="field-hint">"${esc(g.message)}"</small>` : ''}
|
|
</div>
|
|
${isDev ? `<button class="btn small danger clear-gift">Clear</button>` : ''}
|
|
</div>`).join('') : '<p class="field-hint">No pending gifts.</p>';
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = `<h2 class="page-title" style="font-size:16px">Troll</h2><div class="loading"><i></i>Loading</div>`;
|
|
let username = accountId;
|
|
try { username = (await get(`${API}/admin/accounts/${accountId}`)).username || accountId; } catch (error) { }
|
|
|
|
body.innerHTML = `<h2 class="page-title" style="font-size:16px">Troll</h2>
|
|
<div class="troll-banner">
|
|
<img src="/recnet/troll-logo.png" alt="Grim Labubu">
|
|
<div><strong>Troll</strong><span>Developer-only. These are joke/punitive tools - use with care.</span></div>
|
|
</div>
|
|
<div class="profile-actions">
|
|
<button class="btn danger" data-act="fake-box">GRIM LABUBU ${esc(username)}</button>
|
|
<button class="btn danger" data-act="fake-box-no-ban">Send Fake Box</button>
|
|
<button class="btn danger" data-act="kick-to-room">Kick to Room</button>
|
|
<button class="btn danger" data-act="force-into">Force Into My Instance</button>
|
|
</div>`;
|
|
|
|
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 = `<div class="placeholder-card"><i class="fa-solid fa-hammer"></i><p>This tab isn't wired up yet.</p></div>`;
|
|
}
|
|
}
|
|
|
|
async function ageVerificationView() {
|
|
app.innerHTML = `<h1 class="page-title">Age Verification</h1><p class="field-hint">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.</p><div id="ageVerificationList"><div class="loading"><i></i>Loading queue</div></div>`;
|
|
const list = document.querySelector('#ageVerificationList');
|
|
|
|
async function load() {
|
|
list.innerHTML = '<div class="loading"><i></i>Loading queue</div>';
|
|
try {
|
|
const queue = await get(`${API}/admin/age-verification`);
|
|
if (!queue.length) { list.innerHTML = '<p class="field-hint">Nothing awaiting review.</p>'; return; }
|
|
list.innerHTML = queue.map(item => `<div class="room-row" data-code="${esc(item.code)}">
|
|
<div class="meta">
|
|
<strong>${esc(item.displayName)}</strong> <span class="field-hint">@${esc(item.username || '')} · ID ${item.accountId}</span>
|
|
<br><small>Code <code>${esc(item.code)}</code> · ${esc(item.method === 'ManualId' ? 'Manual ID Verification' : 'Face Verification (Manually)')}${item.submittedAt ? ` · ${new Date(item.submittedAt).toLocaleString()}` : ''}</small>
|
|
</div>
|
|
<button class="btn small ghost" data-act="approve">Approve</button>
|
|
<button class="btn small danger" data-act="reject">Reject</button>
|
|
</div>`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
await load();
|
|
}
|
|
|
|
async function liveInstances() {
|
|
app.innerHTML = `
|
|
<h1 class="page-title">Live Room Instances</h1>
|
|
<div class="card">
|
|
<div id="instancesList"><div class="loading"><i></i>Loading live instances</div></div>
|
|
</div>`;
|
|
|
|
const list = document.querySelector('#instancesList');
|
|
|
|
async function loadInstances() {
|
|
list.innerHTML = '<div class="loading"><i></i>Loading live instances</div>';
|
|
try {
|
|
const data = await get(`${API}/admin/instances`);
|
|
if (!data.instances.length) { list.innerHTML = '<p class="field-hint">No players currently online.</p>'; return; }
|
|
list.innerHTML = `<p class="field-hint">${data.totalOnline} players online across ${data.instances.length} instances.</p>` + data.instances.map(instance => `
|
|
<div class="card" style="margin-top:10px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
<h3>${esc(instance.room)} ${instance.roomId ? `(Room #${instance.roomId})` : ''}</h3>
|
|
<button class="btn small danger shutdown-instance" data-instance-id="${instance.roomInstanceId}">Shutdown (Send to Dorm)</button>
|
|
</div>
|
|
<p class="field-hint">${instance.playerCount} player(s) · Instance ID: ${instance.roomInstanceId || 'N/A'}</p>
|
|
<div style="margin-top:10px">
|
|
${instance.players.map(p => `
|
|
<div class="room-row">
|
|
<img src="${esc(p.profileImage || '')}" class="avatar-img" style="width:32px;height:32px" alt="">
|
|
<div class="meta">
|
|
<strong>${esc(p.displayName || p.username)}</strong><br>
|
|
<small>@${esc(p.username)} · ${p.device} · ${p.sockets} socket(s)</small>
|
|
</div>
|
|
<a class="btn small ghost" href="#players/${p.accountId}">View</a>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
await loadInstances();
|
|
}
|
|
|
|
async function roomsList() {
|
|
app.innerHTML = `<h1 class="page-title">Rooms</h1>
|
|
<div class="search-row">
|
|
<input id="roomSearch" placeholder="Search by name, ID, description, or creator ID">
|
|
<label style="display:flex;align-items:center;gap:6px;white-space:nowrap"><input type="checkbox" id="roomIncludeDorms" style="width:auto"> Include dorms</label>
|
|
<button class="btn" id="roomSearchGo">Search</button>
|
|
</div>
|
|
<div id="roomListResults" class="player-list"></div>
|
|
<div class="search-row" id="roomListPager" style="margin-top:10px;display:none">
|
|
<button class="btn ghost small" id="roomListPrev">← Prev</button>
|
|
<span class="field-hint" id="roomListPageInfo" style="align-self:center"></span>
|
|
<button class="btn ghost small" id="roomListNext">Next →</button>
|
|
</div>`;
|
|
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 = '<div class="loading"><i></i>Loading rooms</div>';
|
|
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 => `<a class="player-row" href="#rooms/${r.roomId}"><img src="${esc(r.image || '')}" alt=""><div class="meta"><div class="name">${esc(r.name || 'Untitled room')}</div><div class="sub">ID ${r.roomId} · by ${esc(r.creatorName || r.creatorAccountId)}${r.onlinePlayers ? ` · ${r.onlinePlayers} online` : ''}</div></div></a>`).join('') : '<p class="field-hint">No rooms found.</p>';
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 = '<div class="loading"><i></i>Loading room</div>';
|
|
let room;
|
|
try { room = await get(`${API}/admin/rooms/${roomId}`); }
|
|
catch (error) { app.innerHTML = `<a class="back-link" href="#rooms">← Back to Rooms</a><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`; return; }
|
|
|
|
app.innerHTML = `
|
|
<a class="back-link" href="#rooms">← Back to Rooms</a>
|
|
<div class="profile-header">
|
|
<img src="${esc(room.summary?.image || '')}" alt="">
|
|
<div>
|
|
<div class="name">${esc(room.name || 'Untitled room')}</div>
|
|
<div class="stat-line">${esc(room.description || '(no description)')}</div>
|
|
<div class="stat-line">ID: ${room.roomId} · Creator: ${esc(room.creator?.displayName || room.creator?.username || room.creatorAccountId)}</div>
|
|
<div class="profile-actions">
|
|
<a class="btn ghost" href="/recnet/#room/${roomId}" target="_blank" rel="noopener">Public page</a>
|
|
<a class="btn ghost" href="${API}/admin/rooms/${roomId}/export" target="_blank" rel="noopener">Download backup</a>
|
|
<a class="btn ghost" href="/recnet/#admin" target="_blank" rel="noopener">Full editor (classic panel)</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="details">Details</div>
|
|
<div class="tab" data-tab="subrooms">Subrooms</div>
|
|
<div class="tab" data-tab="roles">Roles</div>
|
|
<div class="tab" data-tab="bans">Bans</div>
|
|
</div>
|
|
<div id="tabBody"></div>`;
|
|
|
|
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 = `<h2 class="page-title" style="font-size:16px">Room Details</h2>
|
|
<div class="card" id="roomSummaryCard"><h2>Summary</h2><p class="field-hint">Loading...</p></div>
|
|
<div class="card dev-block"><h2>Edit Room</h2>
|
|
<p class="field-hint">Developer-only.</p>
|
|
<div class="field-row">
|
|
<label class="field">Name<input id="roomName"></label>
|
|
<label class="field">Image path<input id="roomImage"></label>
|
|
</div>
|
|
<div class="field-row">
|
|
<label class="field">Accessibility<select id="roomAccessibility"><option>Private</option><option>Public</option><option>Unlisted</option></select></label>
|
|
<label class="field">State<select id="roomState"><option>Active</option><option>PendingJunior</option><option>Moderation_PendingReview</option><option>Moderation_Closed</option><option>MarkedForDelete</option></select></label>
|
|
</div>
|
|
<div class="field-row">
|
|
<label class="field">Max players<input type="number" id="roomMaxPlayers" min="1" max="100"></label>
|
|
<label class="field">Minimum level<input type="number" id="roomMinLevel" min="0" max="50"></label>
|
|
</div>
|
|
<label class="field">Tags (comma-separated)<input id="roomTags"></label>
|
|
<label class="field">Description<textarea id="roomDesc" rows="3"></textarea></label>
|
|
<h3>Compatibility & behavior flags</h3>
|
|
<div class="flag-grid" id="roomFlagGrid">${Object.entries(ROOM_FLAG_LABELS).map(([key, label]) => `<label><input type="checkbox" data-flag="${key}"> ${esc(label)}</label>`).join('')}</div>
|
|
<button class="btn" id="saveRoom">Save Changes</button><span class="form-status" id="roomStatus"></span>
|
|
</div>
|
|
<div class="card dev-block"><h2>Room stats</h2>
|
|
<div class="field-row">
|
|
<label class="field">Cheers<input type="number" id="statCheers" min="0"></label>
|
|
<label class="field">Favorites<input type="number" id="statFavorites" min="0"></label>
|
|
<label class="field">Visitors<input type="number" id="statVisitors" min="0"></label>
|
|
<label class="field">Visits<input type="number" id="statVisits" min="0"></label>
|
|
</div>
|
|
<button class="btn" id="saveStats">Save stats</button><span class="form-status" id="statStatus"></span>
|
|
</div>
|
|
<div class="card dev-block"><h2>Transfer ownership</h2>
|
|
<p class="field-hint">Developer-only. The current owner becomes CoOwner.</p>
|
|
<div class="field-row"><label class="field">New owner account ID<input type="number" id="newOwnerId" min="1"></label></div>
|
|
<button class="btn danger" id="transferOwner">Transfer owner</button><span class="form-status" id="transferStatus"></span>
|
|
</div>
|
|
<div class="card dev-block"><h2>Change room ID</h2>
|
|
<p class="field-hint">Developer-only. Renumbers this room's ID and migrates its subrooms, saves, bans, and creator-feature data (cloud variables, inventions, room player data). Anything that only softly references the old ID (players' visited/favorited room lists) is left as-is - it'll just stop resolving.</p>
|
|
<div class="field-row"><label class="field">New room ID<input type="number" id="newRoomId" min="1"></label></div>
|
|
<button class="btn danger" id="changeRoomId">Change ID</button><span class="form-status" id="changeRoomIdStatus"></span>
|
|
</div>`;
|
|
|
|
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 = `<h2>Summary</h2><p class="field-hint">Accessibility: ${esc(room.accessibility)} · State: ${esc(room.state)} · Max players: ${room.maxPlayers} · Min level: ${room.minLevel}</p><p class="field-hint">Tags: ${esc((room.tags || []).join(', ') || 'none')}</p>`;
|
|
} catch (error) {
|
|
body.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
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 = `<h2 class="page-title" style="font-size:16px">Subrooms</h2>
|
|
<div class="card dev-block"><h2>Create subroom</h2><div class="field-row"><label class="field">Name<input id="newSubroomName" maxlength="50"></label></div><button class="btn" id="createSubroom">Add subroom</button><span class="form-status" id="createSubroomStatus"></span></div>
|
|
<div id="subroomList"><div class="loading"><i></i>Loading subrooms</div></div>`;
|
|
|
|
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 = '<p class="field-hint">No subrooms.</p>'; return; }
|
|
list.innerHTML = room.subRooms.map(sr => `
|
|
<div class="card" data-id="${sr.subRoomId}">
|
|
<h2>${esc(sr.name || 'Untitled')} <span class="field-hint">#${sr.subRoomId} · Save #${sr.currentSaveId || 0}${sr.hasData ? ' · Has data' : ''} · ${esc(sr.accessibility)}</span></h2>
|
|
<div class="dev-block">
|
|
<div class="field-row">
|
|
<label class="field">Name<input data-field="name" maxlength="50" value="${esc(sr.name || '')}"></label>
|
|
<label class="field">Max players<input data-field="maxPlayers" type="number" min="1" max="100" value="${sr.maxPlayers || 1}"></label>
|
|
</div>
|
|
<div class="field-row">
|
|
<label class="field">Accessibility<select data-field="accessibility"><option${sr.accessibility === 'Private' ? ' selected' : ''}>Private</option><option${sr.accessibility === 'Public' ? ' selected' : ''}>Public</option><option${sr.accessibility === 'Unlisted' ? ' selected' : ''}>Unlisted</option></select></label>
|
|
<label class="field" style="display:flex;align-items:center;gap:6px;flex-direction:row"><input type="checkbox" data-field="isSandbox" style="width:auto"${sr.isSandbox ? ' checked' : ''}> Sandbox subroom</label>
|
|
</div>
|
|
<label class="field">Unity scene ID<input data-field="unitySceneId" maxlength="200" value="${esc(sr.unitySceneId || '')}"></label>
|
|
<div class="actions-cell">
|
|
<button class="btn small save-subroom">Save</button>
|
|
<button class="btn small ghost clone-subroom">Clone</button>
|
|
<button class="btn small danger delete-subroom">Delete</button>
|
|
</div>
|
|
<h3>Persistence blob pair <span class="field-hint">(files must already exist in CDN/room)</span></h3>
|
|
<div class="field-row">
|
|
<label class="field">RoomBlob ${sr.roomBlobExists ? '<span style="color:var(--success)">✓ found</span>' : '<span style="color:var(--danger)">× missing</span>'}<input data-field="roomBlob" maxlength="255" value="${esc(sr.roomBlob || '')}" placeholder="RoomBlob filename"></label>
|
|
<label class="field">Metadata blob ${sr.metadataBlobExists ? '<span style="color:var(--success)">✓ found</span>' : '<span style="color:var(--danger)">× missing</span>'}<input data-field="metadataBlob" maxlength="255" value="${esc(sr.metadataBlob || '')}" placeholder="Metadata blob filename"></label>
|
|
</div>
|
|
<button class="btn small save-blobs">Save blob pair</button>
|
|
</div>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 = `<h2 class="page-title" style="font-size:16px">Room Roles</h2><div class="card dev-block"><h2>Add / Update Role</h2><p class="field-hint">Developer-only.</p><div class="field-row"><label class="field">Account ID<input id="roleAccountId" type="number" min="1"></label><label class="field">Assigned role<select id="roleType">${ROOM_ASSIGNED_ROLES.map(r => `<option>${r}</option>`).join('')}</select></label><label class="field">Invited role<select id="invitedRoleType">${ROOM_INVITED_ROLES.map(r => `<option>${r}</option>`).join('')}</select></label></div><button class="btn" id="addRole">Save Role</button></div><div class="card"><h2>Current Roles</h2><div id="roleList"><div class="loading"><i></i>Loading roles</div></div></div>`;
|
|
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 = '<p class="field-hint">No roles assigned.</p>'; return; }
|
|
list.innerHTML = room.roles.map(r => `
|
|
<div class="room-row">
|
|
<div class="meta"><strong>${esc(r.player?.displayName || r.player?.username || r.accountId)}</strong><br><small>${esc(r.role)}${r.invitedRole && r.invitedRole !== 'None' ? ` · Invited: ${esc(r.invitedRole)}` : ''}</small></div>
|
|
<button class="btn small danger delete-role dev-only" data-id="${r.accountId}">Remove</button>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = `<h2 class="page-title" style="font-size:16px">Room Bans</h2><div class="card"><h2>Add Ban</h2><input id="banAccountId" placeholder="Account ID"><input id="banReason" placeholder="Reason"><button class="btn" id="addBan">Ban Player</button></div><div class="card"><h2>Current Bans</h2><div id="banList"><div class="loading"><i></i>Loading bans</div></div></div>`;
|
|
|
|
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 = '<p class="field-hint">No bans.</p>'; return; }
|
|
list.innerHTML = room.bans.map(b => `
|
|
<div class="room-row">
|
|
<div style="flex:1"><strong>${esc(b.displayName || b.username || b.accountId)}</strong><br><small>${esc(b.reason || 'No reason')}</small></div>
|
|
<button class="btn small danger delete-ban" data-id="${b.accountId}">Unban</button>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = `<h1 class="page-title">Player Events</h1>
|
|
<div class="card">
|
|
<h2 id="evFormTitle">New event</h2>
|
|
<input type="hidden" id="evEditId">
|
|
<label class="field">Title<input id="evTitle"></label>
|
|
<label class="field">Description<textarea id="evDesc" rows="3"></textarea></label>
|
|
<div class="field-row">
|
|
<label class="field">Starts at<input id="evStart" type="datetime-local"></label>
|
|
<label class="field">Ends at (optional)<input id="evEnd" type="datetime-local"></label>
|
|
</div>
|
|
<label class="field">Image<input type="file" id="evImageFile" accept="image/*"></label>
|
|
<input type="hidden" id="evImageName">
|
|
<div id="evImagePreview" class="field-hint"></div>
|
|
<label><input type="checkbox" id="evPinned"> Pinned</label><br><br>
|
|
<button class="btn" id="evCreate">Create event</button>
|
|
<button class="btn ghost" id="evCancelEdit" hidden>Cancel edit</button>
|
|
<span class="form-status" id="evStatus"></span>
|
|
</div><div id="evList"><div class="loading"><i></i>Loading events</div></div>`;
|
|
|
|
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 = `<img src="${esc(data.url)}" style="max-width:160px;border-radius:8px;margin-top:6px">`;
|
|
} 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 ? `<img src="${esc(e.image)}" style="max-width:160px;border-radius:8px;margin-top:6px">` : '';
|
|
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 = '<p class="field-hint">No events yet.</p>'; return; }
|
|
list.innerHTML = items.map(e => `<div class="card" data-id="${e.eventId ?? e.id}">${e.image ? `<img src="${esc(e.image)}" style="max-width:200px;border-radius:8px;margin-bottom:8px">` : ''}<h2>${esc(e.title)} ${e.pinned ? '📌' : ''}</h2><p>${esc(e.description)}</p><p class="field-hint">${e.startsAt ? new Date(e.startsAt).toLocaleString() : ''}${e.endsAt ? ' - ' + new Date(e.endsAt).toLocaleString() : ''}</p><div class="actions-cell"><button class="btn small ghost edit-ev">Edit</button><button class="btn small danger delete-ev">Delete</button></div></div>`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/\n/g, '<br>');
|
|
}
|
|
|
|
const ANNOUNCEMENT_KINDS = ['info', 'update', 'warning', 'maintenance'];
|
|
|
|
async function announcementsView() {
|
|
app.innerHTML = `<h1 class="page-title">Announcements</h1>
|
|
<div class="card">
|
|
<h2 id="annFormTitle">New announcement</h2>
|
|
<input type="hidden" id="annEditId">
|
|
<label class="field">Title<input id="annTitle"></label>
|
|
<div class="field-row">
|
|
<label class="field">Kind<select id="annKind">${ANNOUNCEMENT_KINDS.map(k => `<option>${k}</option>`).join('')}</select></label>
|
|
</div>
|
|
<label class="field">Body (markdown)<textarea id="annBody" rows="4"></textarea></label>
|
|
<div class="card" style="background:var(--panel-alt)"><h3>Preview</h3><div id="annPreview" class="field-hint"></div></div>
|
|
<label><input type="checkbox" id="annPinned"> Pinned</label> <label><input type="checkbox" id="annPublished" checked> Published</label><br><br>
|
|
<button class="btn" id="annCreate">Post announcement</button>
|
|
<button class="btn ghost" id="annCancelEdit" hidden>Cancel edit</button>
|
|
<span class="form-status" id="annStatus"></span>
|
|
</div><div id="annList"><div class="loading"><i></i>Loading announcements</div></div>`;
|
|
|
|
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) || '<em>Nothing to preview yet.</em>'; });
|
|
|
|
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 = '<p class="field-hint">No announcements yet.</p>'; return; }
|
|
list.innerHTML = items.map(a => `<div class="card" data-id="${a.id}"><h2>${esc(a.title)} <span class="badge">${esc(a.kind)}</span> ${a.pinned ? '📌' : ''} ${a.published ? '' : '<span class="badge">Draft</span>'}</h2><p>${esc(a.bodyMarkdown)}</p><div class="actions-cell"><button class="btn small ghost edit-ann">Edit</button><button class="btn small danger delete-ann">Delete</button></div></div>`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 = '<h1 class="page-title">Community Board</h1><div class="loading"><i></i>Loading board</div>';
|
|
|
|
try {
|
|
const board = await get(`${API}/admin/community-board`);
|
|
renderCommunityBoard(board);
|
|
} catch (error) {
|
|
app.innerHTML = `<h1 class="page-title">Community Board</h1><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`;
|
|
}
|
|
}
|
|
|
|
function renderCommunityBoard(board) {
|
|
app.innerHTML = `
|
|
<h1 class="page-title">Community Board</h1>
|
|
<div class="card">
|
|
<h2>Featured Player</h2>
|
|
<label>Player ID<input type="number" id="fpId" value="${board.featuredPlayer?.id || ''}" placeholder="Leave empty to disable"></label>
|
|
<br><br>
|
|
<label>Title Override<input type="text" id="fpTitle" value="${esc(board.featuredPlayer?.titleOverride || '')}" placeholder="Custom title"></label>
|
|
<br><br>
|
|
<label>URL Override<input type="text" id="fpUrl" value="${esc(board.featuredPlayer?.urlOverride || '')}" placeholder="Custom URL"></label>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Featured Rooms</h2>
|
|
<div id="featuredRoomsList"></div>
|
|
<button class="btn" id="addFeaturedRoom">+ Add Room</button>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Instagram Images</h2>
|
|
<div id="instagramList"></div>
|
|
<button class="btn" id="addInstagram">+ Add Image</button>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Videos</h2>
|
|
<div id="videosList"></div>
|
|
<button class="btn" id="addVideo">+ Add Video</button>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Current Announcement</h2>
|
|
<label>Message<input type="text" id="annMessage" value="${esc(board.currentAnnouncement?.message || '')}" placeholder="Announcement message"></label>
|
|
<br><br>
|
|
<label>More Info URL<input type="text" id="annUrl" value="${esc(board.currentAnnouncement?.moreInfoUrl || '')}" placeholder="Link for more info"></label>
|
|
</div>
|
|
<div class="card dev-block">
|
|
<h2>Scheduled maintenance countdown</h2>
|
|
<p class="field-hint">Pushes a live in-game maintenance notice. Set to 0 to clear it.</p>
|
|
<label>Minutes until maintenance<input type="number" id="maintenanceMinutes" min="0" max="10080" value="0"></label>
|
|
<br><br>
|
|
<button class="btn danger" id="startMaintenance">Start countdown</button>
|
|
<span class="form-status" id="maintenanceStatus"></span>
|
|
</div>
|
|
<button class="btn" id="saveBoard" style="margin-top:20px">Save All Changes</button>
|
|
<button class="btn ghost" id="resetBoard" style="margin-top:20px;margin-left:10px">Reset to Server Data</button>
|
|
<span class="form-status" id="boardStatus"></span>`;
|
|
|
|
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) => `
|
|
<div class="room-row">
|
|
<label>Room ID<input type="number" class="fr-roomId" value="${room.roomId || ''}" data-index="${i}" placeholder="Room ID"></label>
|
|
<label>Room Name<input type="text" class="fr-roomName" value="${esc(room.roomName || '')}" data-index="${i}" placeholder="Room Name"></label>
|
|
<label>Image Name<input type="text" class="fr-imageName" value="${esc(room.imageName || '')}" data-index="${i}" placeholder="Image Name"></label>
|
|
<button class="btn small danger remove-fr" data-index="${i}">Remove</button>
|
|
</div>
|
|
`).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) => `
|
|
<div class="insta-row" style="margin-bottom:10px;padding:10px;background:var(--panel-alt);border-radius:var(--radius)">
|
|
<label>Image URL<input type="text" class="insta-url" value="${esc(img.imageUrl || '')}" data-index="${i}" placeholder="https://..." style="width:400px"></label>
|
|
<button class="btn small danger remove-insta" data-index="${i}">Remove</button>
|
|
</div>
|
|
`).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) => `
|
|
<div class="video-row" style="margin-bottom:10px;padding:10px;background:var(--panel-alt);border-radius:var(--radius)">
|
|
<label>Title<input type="text" class="vid-title" value="${esc(vid.title || '')}" data-index="${i}" placeholder="Video title"></label>
|
|
<label>Description<input type="text" class="vid-desc" value="${esc(vid.description || '')}" data-index="${i}" placeholder="Description"></label>
|
|
<button class="btn small danger remove-vid" data-index="${i}">Remove</button>
|
|
</div>
|
|
`).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 = `<h1 class="page-title">Clubs</h1><div class="search-row"><input id="clubSearch" placeholder="Search clubs"><button class="btn" id="clubSearchGo">Search</button></div><div id="clubListResults" class="player-list"></div>`;
|
|
const input = document.querySelector('#clubSearch'), results = document.querySelector('#clubListResults');
|
|
async function loadClubs() {
|
|
results.innerHTML = '<div class="loading"><i></i>Loading clubs</div>';
|
|
try {
|
|
const data = await get(`${API}/admin/clubs?search=${encodeURIComponent(input.value.trim())}`);
|
|
if (!data.clubs.length) { results.innerHTML = '<p class="field-hint">No clubs found.</p>'; return; }
|
|
results.innerHTML = data.clubs.map(c => `
|
|
<a class="player-row" href="#clubs/${c.clubId}">
|
|
<div class="meta">
|
|
<div class="name">${esc(c.name)}</div>
|
|
<div class="sub">ID ${c.clubId} · ${c.memberCount} members · ${c.visibility}</div>
|
|
</div>
|
|
</a>
|
|
`).join('');
|
|
} catch (error) {
|
|
results.innerHTML = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 = '<div class="loading"><i></i>Loading club</div>';
|
|
let club;
|
|
try { club = await get(`${API}/admin/clubs/${clubId}`); }
|
|
catch (error) { app.innerHTML = `<a class="back-link" href="#clubs">← Back to Clubs</a><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`; return; }
|
|
|
|
const summary = club.summary;
|
|
app.innerHTML = `
|
|
<a class="back-link" href="#clubs">← Back to Clubs</a>
|
|
<div class="profile-header">
|
|
<div>
|
|
<div class="name">${esc(summary.name)}</div>
|
|
<div class="username-sub">${esc(summary.description || '(no description)')}</div>
|
|
<div class="stat-line">ID: ${summary.clubId} · ${summary.memberCount} members</div>
|
|
<div class="stat-line">State: ${summary.state} · Visibility: ${summary.visibility}</div>
|
|
<div class="stat-line">Creator: ${summary.creatorAccountId}</div>
|
|
<div class="profile-actions">
|
|
<button class="btn" data-act="edit">Edit Club</button>
|
|
<a class="btn ghost" href="#clubs">Back</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="members">Members</div>
|
|
</div>
|
|
<div id="tabBody"></div>`;
|
|
|
|
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 = `<h2 class="page-title" style="font-size:16px">Members</h2><div id="membersList"><div class="loading"><i></i>Loading members</div></div>`;
|
|
try {
|
|
const members = await get(`${API}/admin/clubs/${clubId}/members`);
|
|
if (!members.length) { body.innerHTML = '<p class="field-hint">No members.</p>'; return; }
|
|
body.innerHTML = `<table><thead><tr><th>Account ID</th><th>Username</th><th>Display Name</th><th>Role</th><th>Actions</th></tr></thead><tbody>${members.map(m => `
|
|
<tr data-account="${m.accountId}">
|
|
<td>${m.accountId}</td>
|
|
<td>${esc(m.username || 'N/A')}</td>
|
|
<td>${esc(m.displayName || 'N/A')}</td>
|
|
<td>${esc(m.membershipType)}</td>
|
|
<td class="actions-cell"><button class="btn small set-role">Set Role</button><button class="btn small danger remove-member">Remove</button></td>
|
|
</tr>
|
|
`).join('')}</tbody></table>`;
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<h1 class="page-title">Coach Gifting</h1>
|
|
<div class="card">
|
|
<h2>Send Gift</h2>
|
|
<label>Gift Type<select id="giftType">
|
|
<option value="avatar">Avatar Item</option>
|
|
<option value="equipment">Equipment</option>
|
|
<option value="consumable">Consumable</option>
|
|
<option value="tokens">Tokens</option>
|
|
<option value="xp">XP</option>
|
|
<option value="box">Mystery Box</option>
|
|
</select></label>
|
|
<br><br>
|
|
<label>Recipient Account IDs (comma-separated, or leave empty to send to all)<input type="text" id="recipientIds" placeholder="e.g. 123,456,789"></label>
|
|
<br><br>
|
|
<label id="skuLabel">SKU ID<input type="number" id="skuId" placeholder="Search catalog below"></label>
|
|
<br><br>
|
|
<label id="amountLabel">Amount<input type="number" id="amount" value="1"></label>
|
|
<br><br>
|
|
<label id="boxRarityLabel">Box Rarity (10=Common, 20=Uncommon, 30=Rare, 40=Epic, 50=Legendary)<select id="boxRarity">
|
|
<option value="10">Common (10)</option>
|
|
<option value="20">Uncommon (20)</option>
|
|
<option value="30">Rare (30)</option>
|
|
<option value="40">Epic (40)</option>
|
|
<option value="50">Legendary (50)</option>
|
|
</select></label>
|
|
<br><br>
|
|
<label>Box Design<select id="boxDesign"><option value="2" selected>Normal</option><option value="110000">Friendotron</option><option value="custom">Custom...</option></select></label>
|
|
<input type="number" id="boxDesignCustom" placeholder="Custom box design ID" hidden style="margin-top:6px">
|
|
<br><br>
|
|
<label>Message (optional)<input type="text" id="giftMessage" placeholder="Gift message"></label>
|
|
<br><br>
|
|
<label><input type="checkbox" id="sendToAll"> Send to all players</label>
|
|
<br><br>
|
|
<label><input type="checkbox" id="onlineOnly"> Online only</label>
|
|
<br><br>
|
|
<button class="btn" id="sendGift">Send Gift</button>
|
|
<span class="form-status" id="giftStatus"></span>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Gift Catalog</h2>
|
|
<div class="search-row">
|
|
<input id="catalogSearch" placeholder="Search catalog">
|
|
<select id="catalogType">
|
|
<option value="">All Types</option>
|
|
<option value="avatar">Avatar</option>
|
|
<option value="equipment">Equipment</option>
|
|
<option value="consumable">Consumable</option>
|
|
</select>
|
|
<button class="btn" id="searchCatalog">Search</button>
|
|
</div>
|
|
<div id="catalogResults"></div>
|
|
<div class="search-row" style="margin-top:10px;margin-bottom:0">
|
|
<button class="btn ghost small" id="catalogPrev">← Prev</button>
|
|
<span class="field-hint" id="catalogPageInfo" style="align-self:center"></span>
|
|
<button class="btn ghost small" id="catalogNext">Next →</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
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 = '<div class="loading"><i></i>Loading catalog</div>';
|
|
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 = '<p class="field-hint">No items found.</p>'; }
|
|
else {
|
|
results.innerHTML = items.map(item => `
|
|
<div class="room-row clickable" data-sku="${item.skuId}">
|
|
<div class="meta"><strong>${esc(item.friendlyName)}</strong><br><small>SKU ${item.skuId} · ${item.type} ${item.avatarItemId ? `· Avatar #${item.avatarItemId}` : ''}</small></div>
|
|
<button class="btn small select-sku">Select</button>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 = `
|
|
<h1 class="page-title">Server Security</h1>
|
|
<div class="card">
|
|
<h2>Access Control</h2>
|
|
<label><input type="checkbox" id="accountCreationEnabled"> Allow all account creation</label>
|
|
<p class="field-hint">Master switch for every account-creation path, including the admin form.</p>
|
|
<br><br>
|
|
<label><input type="checkbox" id="signupEnabled"> Allow public RecNet signup</label>
|
|
<p class="field-hint">Controls only the website registration form and is also limited by the master switch.</p>
|
|
<br><br>
|
|
<label><input type="checkbox" id="vpnBlockingEnabled"> Block VPNs, proxies, Tor, and hosting IPs</label>
|
|
<p class="field-hint">Checks gameplay and account endpoints. Results are cached and provider outages fail open.</p>
|
|
<p class="field-hint" id="proxyCheckHint"></p>
|
|
<br><br>
|
|
<span class="form-status" id="configStatus"></span>
|
|
</div>`;
|
|
|
|
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 = `
|
|
<h1 class="page-title">Avatar Items</h1>
|
|
<div class="card">
|
|
<div style="margin-bottom:15px">
|
|
<label><input type="radio" name="avatarMode" value="simple" checked> Simple Mode</label>
|
|
<label style="margin-left:20px"><input type="radio" name="avatarMode" value="advanced"> Advanced Mode</label>
|
|
</div>
|
|
<div id="simpleMode">
|
|
<label>Upload JSON file<input type="file" id="avatarJsonFile" accept=".json"></label>
|
|
<p class="field-hint">Upload a JSON file containing the avatar item definition.</p>
|
|
</div>
|
|
<div id="advancedMode" style="display:none">
|
|
<label>Avatar Item JSON<textarea id="avatarJsonText" rows="15" placeholder='{
|
|
"AvatarItemDesc": "05b7af56-71f2-45ba-a377-c105bf6a6f7a,4lwF00Rvb0Kr3FJz07HlOQ",
|
|
"AvatarItemType": 0,
|
|
"PlatformMask": -1,
|
|
"FriendlyName": "Werewolf Wrist (Gilded)",
|
|
"Tooltip": "",
|
|
"Rarity": 0,
|
|
"TagList": null,
|
|
"AvatarItemId": 9796,
|
|
"IsBaseAvatarItem": false,
|
|
"CreatedAt": "2026-08-16T00:00:00.000Z",
|
|
"ThumbnailImage": null
|
|
}'></textarea></label>
|
|
</div>
|
|
<br>
|
|
<button class="btn" id="uploadAvatarItem">Upload Avatar Item</button>
|
|
<span class="form-status" id="avatarStatus"></span>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Browse Avatar Items</h2>
|
|
<div class="search-row"><input id="avatarBrowseSearch" placeholder="Search name, SKU, or avatar item ID"><button class="btn" id="avatarBrowseSearchGo">Search</button></div>
|
|
<div id="avatarBrowseResults"><p class="field-hint">Search to browse the avatar item catalog.</p></div>
|
|
<div class="search-row" id="avatarBrowsePager" style="margin-top:10px;margin-bottom:0;display:none">
|
|
<button class="btn ghost small" id="avatarBrowsePrev">← Prev</button>
|
|
<span class="field-hint" id="avatarBrowsePageInfo" style="align-self:center"></span>
|
|
<button class="btn ghost small" id="avatarBrowseNext">Next →</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
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 = '<div class="loading"><i></i>Loading</div>';
|
|
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 => `
|
|
<div class="room-row">
|
|
${item.thumbnailImage ? `<img src="${esc(item.thumbnailImage)}" alt="">` : ''}
|
|
<div class="meta"><strong>${esc(item.friendlyName)}</strong><br><small>SKU ${item.skuId} · Avatar #${item.avatarItemId} · Rarity ${item.rarity}</small></div>
|
|
</div>
|
|
`).join('') : '<p class="field-hint">No items found.</p>';
|
|
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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 = `<h1 class="page-title">Preferences</h1><div class="card"><div class="loading"><i></i>Loading preferences</div></div>`;
|
|
|
|
try {
|
|
const prefs = await get(`${API}/admin/preferences`);
|
|
app.innerHTML = `
|
|
<h1 class="page-title">Preferences</h1>
|
|
<div class="card">
|
|
<h2>Appearance</h2>
|
|
<label>Theme<select id="themeSelect">
|
|
<option value="dark" ${prefs.theme === 'dark' ? 'selected' : ''}>Dark</option>
|
|
<option value="light" ${prefs.theme === 'light' ? 'selected' : ''}>Light</option>
|
|
</select></label>
|
|
<br><br>
|
|
<label>Accent Color<input type="color" id="accentColor" value="${prefs.accentColor || '#7c3aed'}"></label>
|
|
<br><br>
|
|
<button class="btn" id="savePrefs">Save Preferences</button>
|
|
<span class="form-status" id="prefsStatus"></span>
|
|
</div>`;
|
|
|
|
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 = `<h1 class="page-title">Preferences</h1><div class="card"><p class="form-status error">${esc(error.message)}</p></div>`;
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<h1 class="page-title">Player Reports</h1>
|
|
<div class="card">
|
|
<label>Status
|
|
<select id="reportStatusFilter">
|
|
<option value="Pending">Pending</option>
|
|
<option value="Banned">Banned</option>
|
|
<option value="TimedOut">Timed Out</option>
|
|
<option value="NoAction">No Action</option>
|
|
<option value="all">All</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="card">
|
|
<div id="playerReportList"><div class="loading"><i></i>Loading reports</div></div>
|
|
</div>`;
|
|
|
|
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 = '<div class="loading"><i></i>Loading reports</div>';
|
|
try {
|
|
const reports = await get(`${API}/admin/reports/players?status=${encodeURIComponent(statusFilter.value)}`);
|
|
if (!reports.length) { list.innerHTML = '<p class="field-hint">No reports here.</p>'; return; }
|
|
list.innerHTML = reports.map(r => `
|
|
<div class="room-row">
|
|
<div style="flex:1">
|
|
<strong>${esc(r.reportedUsername || (r.reportedPlayerId ? `Account #${r.reportedPlayerId}` : 'Unknown player'))}</strong>
|
|
<span class="badge">${esc(r.status)}</span>
|
|
<br><small>Reported by ${esc(r.reporterUsername || `Account #${r.reporterId}`)} on ${new Date(r.createdAt).toLocaleString()}</small>
|
|
${r.reportCategory != null ? `<br><small>Category #${esc(r.reportCategory)}</small>` : ''}
|
|
${r.details ? `<br><small>${esc(r.details)}</small>` : ''}
|
|
${r.roomId ? `<br><small>Room ${esc(r.roomId)}${r.roomInstanceType ? ` (${esc(r.roomInstanceType)})` : ''}</small>` : ''}
|
|
${r.resolutionNote ? `<br><small>Resolution: ${esc(r.resolutionNote)}</small>` : ''}
|
|
</div>
|
|
${r.status === 'Pending' ? `
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:flex-start">
|
|
<button class="btn small danger act-ban" data-id="${esc(r.id)}">Ban</button>
|
|
<button class="btn small warn act-timeout" data-id="${esc(r.id)}">Timeout</button>
|
|
<button class="btn small ghost act-noaction" data-id="${esc(r.id)}">No Action</button>
|
|
</div>` : ''}
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
statusFilter.addEventListener('change', loadReports);
|
|
await loadReports();
|
|
}
|
|
|
|
async function bugReportsView() {
|
|
app.innerHTML = `
|
|
<h1 class="page-title">Bug Reports</h1>
|
|
<div class="card">
|
|
<label>Status
|
|
<select id="bugStatusFilter">
|
|
<option value="Open">Open</option>
|
|
<option value="Closed">Closed</option>
|
|
<option value="all">All</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="card">
|
|
<div id="bugReportList"><div class="loading"><i></i>Loading bug reports</div></div>
|
|
</div>`;
|
|
|
|
const statusFilter = document.querySelector('#bugStatusFilter');
|
|
const list = document.querySelector('#bugReportList');
|
|
|
|
async function loadBugReports() {
|
|
list.innerHTML = '<div class="loading"><i></i>Loading bug reports</div>';
|
|
try {
|
|
const reports = await get(`${API}/admin/reports/bugs?status=${encodeURIComponent(statusFilter.value)}`);
|
|
if (!reports.length) { list.innerHTML = '<p class="field-hint">No bug reports here.</p>'; return; }
|
|
list.innerHTML = reports.map(r => `
|
|
<div class="room-row">
|
|
<div style="flex:1">
|
|
<strong>${esc(r.category || 'Uncategorized')}</strong>
|
|
<span class="badge">${esc(r.status)}</span>
|
|
<br><small>From ${esc(r.reporterUsername || `Account #${r.reporterId}`)} on ${new Date(r.createdAt).toLocaleString()}</small>
|
|
<br><small>${esc(r.description || '')}</small>
|
|
</div>
|
|
<button class="btn small ${r.status === 'Open' ? 'ghost' : 'danger'} toggle-bug" data-id="${esc(r.id)}" data-next="${r.status === 'Open' ? 'Closed' : 'Open'}">${r.status === 'Open' ? 'Close' : 'Reopen'}</button>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
statusFilter.addEventListener('change', loadBugReports);
|
|
await loadBugReports();
|
|
}
|
|
|
|
async function ipBansView() {
|
|
app.innerHTML = `
|
|
<h1 class="page-title">IP Bans</h1>
|
|
<div class="card">
|
|
<h2>Add IP Ban</h2>
|
|
<label>IP / CIDR<input id="ipBanNetwork" placeholder="203.0.113.4 or 203.0.113.0/24"></label>
|
|
<br><br>
|
|
<label>Reason<input id="ipBanReason" placeholder="Ban reason"></label>
|
|
<br><br>
|
|
<button class="btn danger" id="addIpBan">Add IP Ban</button>
|
|
<span class="form-status" id="ipBanStatus"></span>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Active Bans</h2>
|
|
<div id="ipBanList"><div class="loading"><i></i>Loading IP bans</div></div>
|
|
</div>`;
|
|
|
|
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 = '<div class="loading"><i></i>Loading IP bans</div>';
|
|
try {
|
|
const bans = await get(`${API}/admin/ip-bans`);
|
|
if (!bans.length) { list.innerHTML = '<p class="field-hint">No IP addresses or ranges are banned.</p>'; return; }
|
|
list.innerHTML = bans.map(b => `
|
|
<div class="room-row">
|
|
<div style="flex:1"><strong>${esc(b.network)}</strong><br><small>${esc(b.reason || 'Blocked by an administrator.')}</small><br><small>Added ${new Date(b.createdAt).toLocaleString()} by account #${b.createdByAccountId || 0}</small></div>
|
|
<button class="btn small danger remove-ip" data-id="${esc(b.id)}">Remove</button>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<h1 class="page-title">Steam Blacklist</h1>
|
|
<div class="card">
|
|
<h2>Blacklist Steam ID</h2>
|
|
<p class="field-hint">Every Steam ID is allowed by default. Add an ID here to return HTTP 403 on cached login, account creation, password login, refresh, and RecNet login.</p>
|
|
<label>Steam ID<input id="steamId" placeholder="7656119..." type="number"></label>
|
|
<br><br>
|
|
<label>Reason<input id="steamReason" placeholder="Why this Steam ID is blocked"></label>
|
|
<br><br>
|
|
<button class="btn danger" id="addSteam">Blacklist Steam ID</button>
|
|
<span class="form-status" id="steamStatus"></span>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Blacklisted IDs</h2>
|
|
<div id="steamList"><div class="loading"><i></i>Loading Steam blacklist</div></div>
|
|
</div>`;
|
|
|
|
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 = '<div class="loading"><i></i>Loading Steam blacklist</div>';
|
|
try {
|
|
const items = await get(`${API}/admin/steam-blacklist`);
|
|
if (!items.length) { list.innerHTML = '<p class="field-hint">No Steam IDs are blacklisted.</p>'; return; }
|
|
list.innerHTML = items.map(item => `
|
|
<div class="room-row">
|
|
<div style="flex:1"><strong>${esc(item.steamId)}</strong><br><small>${esc(item.reason || 'Blacklisted by an administrator.')}</small><br><small>Added by ${esc(item.addedByDisplayName || item.addedByByUsername || `Account #${item.addedByAccountId}`)} on ${new Date(item.addedAt || item.AddedAt).toLocaleString()}</small></div>
|
|
<button class="btn small danger remove-steam" data-steam="${esc(item.steamId)}">Unblacklist</button>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<h1 class="page-title">Shop Controls</h1>
|
|
<div class="card">
|
|
<h2>Shop Actions</h2>
|
|
<button class="btn" id="refreshShop">Refresh Shop Now</button>
|
|
<span class="form-status" id="shopStatus"></span>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Custom Shop Items</h2>
|
|
<div id="customShopItems"><div class="loading"><i></i>Loading custom items</div></div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Add Custom Item</h2>
|
|
<input id="shopSearch" placeholder="Search item name, SKU, or avatar item ID">
|
|
<div id="shopSearchResults"></div>
|
|
<div class="search-row" id="shopSearchPager" style="margin-top:10px;margin-bottom:0;display:none">
|
|
<button class="btn ghost small" id="shopSearchPrev">← Prev</button>
|
|
<span class="field-hint" id="shopSearchPageInfo" style="align-self:center"></span>
|
|
<button class="btn ghost small" id="shopSearchNext">Next →</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
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 = '<div class="loading"><i></i>Loading custom items</div>';
|
|
try {
|
|
const items = await get(`${API}/admin/shop`);
|
|
if (!items.customItems || !items.customItems.length) { customItems.innerHTML = '<p class="field-hint">No custom items pinned. All 10 slots are random 5-stars.</p>'; return; }
|
|
customItems.innerHTML = items.customItems.map(item => `
|
|
<div class="room-row">
|
|
<div style="flex:1"><strong>${esc(item.friendlyName)}</strong><br><small>SKU ${item.skuId} · ${item.price} tokens</small></div>
|
|
<button class="btn small danger remove-shop" data-sku="${item.skuId}">Remove</button>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = '<p class="field-hint">No items found.</p>'; }
|
|
else {
|
|
searchResults.innerHTML = items.map(item => `
|
|
<div class="room-row clickable" data-sku="${item.skuId}">
|
|
<div class="meta"><strong>${esc(item.friendlyName)}</strong><br><small>SKU ${item.skuId} · Avatar #${item.avatarItemId || 'N/A'}</small></div>
|
|
<button class="btn small add-shop">Add</button>
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
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 `<div style="color:${color};font-family:monospace;font-size:12px;padding:2px 0">${r.success ? '✓' : '✗'} ${r.name}${detail}</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
if (job.status === 'completed' || job.status === 'failed') return;
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
}
|
|
}
|
|
|
|
async function roomImporterView() {
|
|
app.innerHTML = `
|
|
<h1 class="page-title">Room Importer</h1>
|
|
<div class="card">
|
|
<h2>Import Room</h2>
|
|
<p class="field-hint">Upload a room export ZIP, paste Unity scene JSON metadata, or import straight from an archive URL (e.g. an <a href="https://archive.splootybean.com/Rooms/" target="_blank" rel="noopener">archive.splootybean.com</a>-style room folder). Baked asset bundles are picked up automatically in all three modes - no separate step needed.</p>
|
|
<br>
|
|
<label>Import Method<select id="importMethod">
|
|
<option value="zip">ZIP File Upload</option>
|
|
<option value="url">Import from URL</option>
|
|
<option value="json">JSON Metadata</option>
|
|
</select></label>
|
|
<br><br>
|
|
<label><input type="checkbox" id="massImportToggle"> Mass import (multiple rooms at once)</label>
|
|
<p class="field-hint" id="massImportHint" style="display:none">ZIP: a folder of multiple room exports (e.g. a whole archived <code>Rooms/</code> folder), zipped up - every folder inside containing a room.json gets imported. URL: point at the index folder itself (e.g. <code>.../Rooms/</code>) instead of one room's folder, and every room folder listed under it gets crawled and imported. JSON: paste a JSON array of room metadata objects instead of one object. Runs as a background job so it isn't limited by how long a single request can stay open - the panel below polls for progress.</p>
|
|
<br>
|
|
<div id="zipSection">
|
|
<label id="roomZipLabel">Room Export ZIP (.zip)<input type="file" id="roomZip" accept=".zip"></label>
|
|
<br><br>
|
|
<label>Creator Account ID (optional, defaults to you)<input type="number" id="creatorAccountId" placeholder="Account ID"></label>
|
|
<br><br>
|
|
<label><input type="checkbox" id="replaceExisting" checked> Replace existing room if it exists</label>
|
|
</div>
|
|
<div id="urlSection" style="display:none">
|
|
<label id="roomUrlLabel">Room folder URL<input type="url" id="roomUrl" placeholder="https://archive.splootybean.com/Rooms/Showdown/"></label>
|
|
<p class="field-hint" id="roomUrlHint">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.</p>
|
|
<br>
|
|
<label>Creator Account ID (optional, defaults to you)<input type="number" id="urlCreatorAccountId" placeholder="Account ID"></label>
|
|
<br><br>
|
|
<label><input type="checkbox" id="urlReplaceExisting" checked> Replace existing room if it exists</label>
|
|
</div>
|
|
<div id="jsonSection" style="display:none">
|
|
<label id="roomJsonLabel">Unity Scene JSON Metadata<textarea id="roomJson" rows="10" placeholder="Paste Unity scene JSON metadata here..."></textarea></label>
|
|
</div>
|
|
<br><br>
|
|
<button class="btn" id="importRoom">Import Room</button>
|
|
<span class="form-status" id="importStatus"></span>
|
|
<div id="massProgress" style="max-height:320px;overflow-y:auto;margin-top:10px"></div>
|
|
</div>`;
|
|
|
|
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 = `
|
|
<h1 class="page-title">Server Logs</h1>
|
|
<div class="card">
|
|
<p class="field-hint">The last few thousand lines of server console output, kept in memory (cleared on restart). Not a substitute for a real log file, but enough to see what's happening right now.</p>
|
|
<div style="display:flex;gap:10px;align-items:center;margin:10px 0">
|
|
<label>Lines<input type="number" id="logsTake" value="500" min="50" max="5000" style="width:90px"></label>
|
|
<button class="btn" id="logsRefresh">Refresh</button>
|
|
<label><input type="checkbox" id="logsAutoRefresh"> Auto-refresh (5s)</label>
|
|
</div>
|
|
<pre id="logsOutput" style="background:#111;color:#ddd;padding:12px;border-radius:6px;max-height:600px;overflow:auto;font-size:12px;white-space:pre-wrap;word-break:break-all"></pre>
|
|
</div>`;
|
|
|
|
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 = `
|
|
<h1 class="page-title">Loser Scanner Logs</h1>
|
|
<div class="card">
|
|
<p class="field-hint">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.</p>
|
|
<div style="display:flex;gap:10px;align-items:center;margin:10px 0;flex-wrap:wrap">
|
|
<span id="scannerTotal" class="field-hint"></span>
|
|
<input id="scannerIpFilter" placeholder="Filter by IP" style="width:160px">
|
|
<button class="btn" id="scannerRefresh">Refresh</button>
|
|
</div>
|
|
<div id="scannerResults"></div>
|
|
<div style="margin-top:10px"><button class="btn" id="scannerLoadMore" style="display:none">Load more</button></div>
|
|
</div>`;
|
|
|
|
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 = '<p class="field-hint">No scanner attempts logged yet.</p>';
|
|
return;
|
|
}
|
|
resultsEl.innerHTML = rows.map(r => `
|
|
<div style="border-bottom:1px solid #333;padding:6px 0;font-family:monospace;font-size:12px${r.matchedPattern ? ';background:rgba(255,85,85,0.08)' : ''}">
|
|
<div>${esc(new Date(r.timestamp).toLocaleString())} · <strong>${esc(r.ipAddress)}</strong> · ${esc(r.method)} ${esc(r.path)}${r.queryString ? esc(r.queryString) : ''}</div>
|
|
${r.matchedPattern ? `<div style="color:#ff5555">matched: ${esc(r.matchedPattern)}</div>` : ''}
|
|
${r.userAgent ? `<div style="color:#888">${esc(r.userAgent)}</div>` : ''}
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<h1 class="page-title">Anticheat Logs</h1>
|
|
<div class="card">
|
|
<p class="field-hint">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.</p>
|
|
<div style="display:flex;gap:10px;align-items:center;margin:10px 0;flex-wrap:wrap">
|
|
<span id="acTotal" class="field-hint"></span>
|
|
<input id="acIpFilter" placeholder="Filter by IP" style="width:160px">
|
|
<button class="btn" id="acRefresh">Refresh</button>
|
|
</div>
|
|
<div id="acResults"></div>
|
|
<div style="margin-top:10px"><button class="btn" id="acLoadMore" style="display:none">Load more</button></div>
|
|
</div>`;
|
|
|
|
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 = '<p class="field-hint">No anticheat flags logged yet.</p>';
|
|
return;
|
|
}
|
|
resultsEl.innerHTML = rows.map(r => `
|
|
<div style="border-bottom:1px solid #333;padding:6px 0;font-family:monospace;font-size:12px;background:rgba(255,85,85,0.08)">
|
|
<div>${esc(new Date(r.timestamp).toLocaleString())} · <strong>${esc(r.ipAddress)}</strong>${r.accountId ? ` · <a href="#players/${r.accountId}">account ${r.accountId}</a>` : ''} · steam ${esc(r.steamId || '0')} · build ${esc(r.build || '?')}</div>
|
|
<div style="color:#ff5555">${esc(r.flags)}</div>
|
|
${r.userAgent ? `<div style="color:#888">${esc(r.userAgent)}</div>` : ''}
|
|
</div>
|
|
`).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 = `<p class="form-status error">${esc(error.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
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();
|