'); }
function inventoryAvatarRow(item) { const name = item.friendlyName || item.avatarItemDesc || `Avatar item ${item.avatarItemId}`; return `
${esc(name)} ${esc(item.avatarItemDesc || '')}${item.avatarItemId ? ` · #${item.avatarItemId}` : ''}
${item.owned ? 'Remove' : 'Give'} `; }
function inventoryConsumableRow(item) { const name = item.friendlyName || item.consumableItemDesc || `Consumable ${item.consumableItemId}`; return `
`; }
async function loadAdminInventory(card, term = '') { const id = card.dataset.account, results = card.querySelector('.admin-inventory-results'); if (!results) return; results.innerHTML = '
Loading inventory
'; try { const data = await get(`/recnet/api/admin/accounts/${id}/inventory?search=${encodeURIComponent(term)}`); const avatarItems = Array.isArray(data.avatarItems) ? data.avatarItems : [], consumables = Array.isArray(data.consumables) ? data.consumables : []; results.innerHTML = `
Avatar items ${avatarItems.filter(x => x.owned).length} owned in these results
${avatarItems.length ? avatarItems.map(inventoryAvatarRow).join('') : '
No avatar items match.
'}
Consumables Set 0 to remove
${consumables.length ? consumables.map(inventoryConsumableRow).join('') : '
No consumables match.
'}
`; card.dataset.inventoryLoaded = 'true'; } catch (error) { results.innerHTML = `
${esc(error.message || 'Could not load inventory.')}
`; } }
async function loadOverview() { const box = document.querySelector('#adminOverview'); try {
const o = await get('/recnet/api/admin/overview');
box.innerHTML = [['users', 'Accounts', o.accounts], ['user-shield', 'Admins', o.admins], ['lock', 'Mod Locks', o.moderationLocks], ['circle-check', 'Verified', o.verified], ['crown', 'RR+', o.rrPlus], ['door-open', 'Rooms', o.rooms], ['image', 'Photos', o.photos], ['heart', 'Cheers', o.cheers], ['comment', 'Comments', o.comments]].map(x => `
${x[2]} ${x[1]}
`).join('');
}
catch {
box.innerHTML = '
Could not load server overview.
';
} }
async function load() { list.innerHTML = '
Loading accounts
'; const response = await recnetFetch('/recnet/api/admin/accounts?search=' + encodeURIComponent(search.value)); if (!response.ok) {
list.innerHTML = '
Admin access denied.
';
return;
} const data = await response.json(); list.innerHTML = data.map(inventoryAccountCard).join('') || '
No accounts found.
'; }
async function adminAction(url, method, body) { const response = await recnetFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), data = await response.json().catch(() => ({})); if (!response.ok)
throw new Error(data.error || 'Admin action failed.'); return data; }
list.addEventListener('click', async (e) => { const button = e.target.closest('[data-action="add-balance"],[data-action="set-balance"]'); if (!button)
return; e.stopImmediatePropagation(); const card = button.closest('.admin-account'), id = card.dataset.account, amount = Number.parseInt(card.querySelector('.admin-balance-amount').value, 10); if (!Number.isInteger(amount) || amount < 0) {
alert('Enter a valid non-negative token amount.');
return;
} try {
button.disabled = true;
const result = await adminAction(`/recnet/api/admin/accounts/${id}/balance`, 'POST', { amount, add: button.dataset.action === 'add-balance' });
alert(`Token balance is now ${Number(result.balance).toLocaleString()}.`);
await Promise.all([load(), loadOverview()]);
}
catch (error) {
alert(error.message);
}
finally {
button.disabled = false;
} });
list.addEventListener('click', async (e) => { const button = e.target.closest('[data-action="save-details"]'); if (!button)
return; e.stopImmediatePropagation(); const card = button.closest('.admin-account'), id = card.dataset.account, level = Number.parseInt(card.querySelector('.admin-level').value, 10), xp = Number.parseInt(card.querySelector('.admin-xp').value, 10), availableUsernameChanges = Number.parseInt(card.querySelector('.admin-username-changes').value, 10), personalPronouns = Number.parseInt(card.querySelector('.admin-pronouns').value, 10); if (!Number.isInteger(level) || level < 1 || level > 50) {
alert('Level must be between 1 and 50.');
return;
} if (!Number.isInteger(xp) || xp < 0) {
alert('XP cannot be negative.');
return;
} if (!Number.isInteger(availableUsernameChanges) || availableUsernameChanges < 0) {
alert('Username changes cannot be negative.');
return;
} if (!Number.isInteger(personalPronouns) || personalPronouns < 0 || personalPronouns > 63) {
alert('Pronoun flags must be between 0 and 63.');
return;
} try {
button.disabled = true;
await adminAction(`/recnet/api/admin/accounts/${id}/details`, 'PUT', { level, xp, availableUsernameChanges, personalPronouns, isJunior: card.querySelector('.admin-is-junior').checked, displayEmoji: card.querySelector('.admin-display-emoji').value, profileImage: card.querySelector('.admin-profile-image-path').value, bannerImage: card.querySelector('.admin-banner-image-path').value });
await Promise.all([load(), loadOverview()]);
}
catch (error) {
alert(error.message);
}
finally {
button.disabled = false;
} });
list.addEventListener('keydown', async e => { if (e.key !== 'Enter' || !e.target.matches('.admin-inventory-search')) return; e.preventDefault(); const card = e.target.closest('.admin-account'); await loadAdminInventory(card, e.target.value); });
list.addEventListener('click', async e => { const button = e.target.closest('[data-inventory-action]'); if (!button) return; e.stopImmediatePropagation(); const card = button.closest('.admin-account'), id = card.dataset.account, action = button.dataset.inventoryAction; try { button.disabled = true; if (action === 'search') { const input = card.querySelector('.admin-inventory-search'); await loadAdminInventory(card, input?.value || ''); return; } if (action === 'avatar-toggle') { const owned = button.dataset.owned === 'true'; await adminAction(`/recnet/api/admin/accounts/${id}/avatar-items`, 'POST', { avatarItemId: Number(button.dataset.itemId) || 0, avatarItemDesc: button.dataset.itemDesc || '', owned: !owned }); const input = card.querySelector('.admin-inventory-search'); await loadAdminInventory(card, input?.value || ''); return; } if (action === 'consumable-save') { const row = button.closest('.admin-inventory-row'), quantity = Number.parseInt(row.querySelector('.admin-consumable-quantity').value, 10); if (!Number.isInteger(quantity) || quantity < 0 || quantity > 100000) throw new Error('Quantity must be between 0 and 100000.'); await adminAction(`/recnet/api/admin/accounts/${id}/consumables`, 'POST', { consumableItemId: Number(button.dataset.itemId) || 0, consumableItemDesc: button.dataset.itemDesc || '', quantity }); const input = card.querySelector('.admin-inventory-search'); await loadAdminInventory(card, input?.value || ''); } } catch (error) { alert(error.message); } finally { button.disabled = false; } });
list.addEventListener('click', async e => { const button = e.target.closest('[data-action="manage"]'); if (!button)
return; e.stopImmediatePropagation(); const card = button.closest('.admin-account'), tools = card.querySelector('.admin-tools'); tools.hidden = !tools.hidden; button.textContent = tools.hidden ? 'Manage user & tokens' : 'Close'; if (!tools.hidden) { if (!card.dataset.inventoryLoaded) await loadAdminInventory(card); requestAnimationFrame(() => card.querySelector('.admin-token-section')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })); } });
list.addEventListener('click', async (e) => { const button = e.target.closest('[data-action="apply-ban"]'); if (!button)
return; e.stopImmediatePropagation(); const card = button.closest('.admin-account'), id = card.dataset.account, reason = card.querySelector('.admin-ban-reason').value.trim(), linkBan = card.querySelector('.admin-link-ban').checked, durationAmount = Number.parseInt(card.querySelector('.admin-ban-duration-amount').value, 10), durationUnit = card.querySelector('.admin-ban-duration-unit').value.toLowerCase(); if (!reason) {
alert('Enter a ban reason first.');
return;
} if (durationUnit !== 'permanent' && (!Number.isInteger(durationAmount) || durationAmount < 1)) {
alert('Choose a valid ban duration.');
return;
} if (!confirm(`Ban account #${id}${linkBan ? ' and its linked accounts' : ''} for ${durationUnit === 'permanent' ? 'a permanent duration' : durationAmount + ' ' + durationUnit}?`))
return; try {
button.disabled = true;
const result = await adminAction(`/recnet/api/admin/accounts/${id}/ban`, 'POST', { reason, linkBan, durationAmount: durationUnit === 'permanent' ? 1 : durationAmount, durationUnit });
alert(`Game ban applied to ${result.affectedCount} account${result.affectedCount === 1 ? '' : 's'}.`);
await Promise.all([load(), loadOverview()]);
}
catch (error) {
alert(error.message);
}
finally {
button.disabled = false;
} });
list.addEventListener('click', async (e) => { const button = e.target.closest('[data-action]'); if (!button)
return; const action = button.dataset.action; if (!['apply-ban', 'remove-ban', 'apply-lock', 'remove-lock'].includes(action))
return; e.stopImmediatePropagation(); const card = button.closest('.admin-account'), id = card.dataset.account; try {
button.disabled = true;
if (action === 'apply-ban') {
const reason = card.querySelector('.admin-ban-reason').value.trim(), linkBan = card.querySelector('.admin-link-ban').checked;
if (!reason) {
alert('Enter a ban reason first.');
return;
}
if (!confirm(`Ban account #${id}${linkBan ? ' and its linked accounts' : ''}?`))
return;
const result = await adminAction(`/recnet/api/admin/accounts/${id}/ban`, 'POST', { reason, linkBan });
alert(`Banned ${result.affectedCount} account${result.affectedCount === 1 ? '' : 's'}.`);
}
else if (action === 'remove-ban') {
if (!confirm(`Unban account #${id}?`))
return;
await adminAction(`/recnet/api/admin/accounts/${id}/ban`, 'DELETE', {});
}
else if (action === 'apply-lock') {
if (!confirm(`Apply a Moderation Lock to account #${id}? This uses the 32-bit maximum duration and the exact reason “Moderation Lock”.`))
return;
await adminAction(`/recnet/api/admin/accounts/${id}/moderation-lock`, 'POST', {});
}
else if (action === 'remove-lock') {
if (!confirm(`Remove the Moderation Lock from account #${id}?`))
return;
await adminAction(`/recnet/api/admin/accounts/${id}/moderation-lock`, 'DELETE', { removeLinkedAccounts: false });
}
await Promise.all([load(), loadOverview()]);
}
catch (error) {
alert(error.message);
}
finally {
button.disabled = false;
} });
list.addEventListener('click', async (e) => { const button = e.target.closest('[data-action]'); if (!button)
return; const card = button.closest('.admin-account'), id = card.dataset.account, action = button.dataset.action; if (action === 'manage') {
const tools = card.querySelector('.admin-tools');
tools.hidden = !tools.hidden;
button.textContent = tools.hidden ? 'Manage' : 'Close';
return;
} try {
button.disabled = true;
if (action === 'save-profile') {
await adminAction(`/recnet/api/admin/accounts/${id}/profile`, 'PUT', { displayName: card.querySelector('.admin-display-name').value, username: card.querySelector('.admin-username').value, bio: card.querySelector('.admin-bio').value, email: card.querySelector('.admin-email').value });
}
else if (action === 'send-coach-message') {
const field = card.querySelector('.admin-coach-message-body'), message = field.value.trim();
if (!message) {
alert('Type a message first.');
return;
}
await adminAction(`/recnet/api/admin/accounts/${id}/message`, 'POST', { message });
field.value = '';
}
else if (action === 'add-platform') {
await adminAction(`/recnet/api/admin/accounts/${id}/platforms`, 'POST', { platform: card.querySelector('.admin-platform-select').value, platformId: card.querySelector('.admin-platform-id').value, enabled: true });
}
else if (action === 'remove-platform') {
if (!confirm(`Unlink ${button.dataset.platform} ${button.dataset.platformId} from account #${id}?`))
return;
await adminAction(`/recnet/api/admin/accounts/${id}/platforms`, 'POST', { platform: button.dataset.platform, platformId: button.dataset.platformId, enabled: false });
}
else if (action === 'add-role') {
const role = card.querySelector('.admin-role-select').value;
await adminAction(`/recnet/api/admin/accounts/${id}/roles`, 'POST', { role, enabled: true });
}
else if (action === 'remove-role') {
if (!confirm(`Remove the ${button.dataset.role} role from account #${id}?`))
return;
await adminAction(`/recnet/api/admin/accounts/${id}/roles`, 'POST', { role: button.dataset.role, enabled: false });
}
else if (action === 'apply-lock') {
const reason = card.querySelector('.admin-lock-reason').value.trim(), linkBan = card.querySelector('.admin-link-ban').checked;
if (!reason) {
alert('Enter a ban reason first.');
return;
}
if (!confirm(`Apply a permanent Moderation Lock to account #${id}${linkBan ? ' and every linked account' : ''}?`))
return;
const result = await adminAction(`/recnet/api/admin/accounts/${id}/moderation-lock`, 'POST', { reason, linkBan });
alert(`Moderation Lock applied to ${result.affectedCount} account${result.affectedCount === 1 ? '' : 's'}.`);
}
else if (action === 'remove-lock') {
if (!confirm(`Remove the Moderation Lock from account #${id}?`))
return;
const removeLinked = confirm('Also unlock every account included in the same Link Ban?');
const result = await adminAction(`/recnet/api/admin/accounts/${id}/moderation-lock`, 'DELETE', { removeLinkedAccounts: removeLinked });
alert(`Removed ${result.affectedCount} Moderation Lock${result.affectedCount === 1 ? '' : 's'}.`);
}
else if (action === 'force-join') {
const result = await adminAction(`/recnet/api/admin/accounts/${id}/force-join-instance`, 'POST', {});
alert(result.delivered
? `Invite pushed live - they should see a prompt in-game to join "${result.roomName || 'your instance'}".`
: `They're not currently connected, so nothing popped up live. The invite is saved and their server record now shows them in "${result.roomName || 'your instance'}" - it'll surface next time they connect.`);
}
else if (action === 'force-me-into') {
const result = await adminAction(`/recnet/api/admin/accounts/${id}/force-me-into`, 'POST', {});
alert(result.delivered
? `You've been dropped into "${result.roomName}" - it should pop up live for you.`
: `Your server record now shows you in "${result.roomName}" - it'll surface next time your client checks in.`);
}
else if (action === 'force-user-into') {
const targetInput = prompt('Account ID of the person whose instance to move this user into:', '');
if (targetInput === null)
return;
const targetAccountId = Number(targetInput.trim());
if (!Number.isInteger(targetAccountId) || targetAccountId < 1) {
alert('Enter a valid account ID.');
return;
}
const result = await adminAction(`/recnet/api/admin/accounts/${id}/force-into/${targetAccountId}`, 'POST', {});
alert(result.delivered
? `Moved into "${result.roomName}" - it should pop up live for them. The other account was never notified.`
: `Their server record now shows them in "${result.roomName}" - it'll surface next time their client checks in. The other account was never notified.`);
}
else if (action === 'troll-kick') {
const roomName = prompt('Room name to send them to (leave blank to use your own current instance):', '');
if (roomName === null)
return;
const result = await adminAction(`/recnet/api/admin/accounts/${id}/troll/kick-to-room`, 'POST', roomName.trim() ? { roomName: roomName.trim() } : {});
alert(`Disconnected ${result.disconnectedSockets} live connection(s) and redirected them to "${result.roomName}".`);
}
else if (action === 'troll-fakebox') {
const amountInput = prompt('Fake token amount to show them:', '5000');
if (amountInput === null)
return;
const tokenAmount = Number(amountInput);
if (!Number.isInteger(tokenAmount) || tokenAmount < 1) {
alert('Enter a whole number of tokens.');
return;
}
const result = await adminAction(`/recnet/api/admin/accounts/${id}/troll/fakebox-and-ban`, 'POST', { tokenAmount, banImmediately: false });
alert(`Fake "Yo i levelled up!" box sent (showing level ${result.fakeLevel}, ${result.tokenAmount.toLocaleString()} tokens) - level-up effect played too.`);
}
else if (action === 'troll-fakebox-ban') {
const amountInput = prompt('Fake token amount to show them before the ban:', '5000');
if (amountInput === null)
return;
const tokenAmount = Number(amountInput);
if (!Number.isInteger(tokenAmount) || tokenAmount < 1) {
alert('Enter a whole number of tokens.');
return;
}
if (!confirm(`Send the fake box, then immediately permaban #${id} for "Cheating" and auto link-ban any linked accounts?`))
return;
const result = await adminAction(`/recnet/api/admin/accounts/${id}/troll/fakebox-and-ban`, 'POST', { tokenAmount, banImmediately: true });
alert(`Sent fake box (${result.tokenAmount.toLocaleString()} tokens) and permabanned ${result.affectedCount} account(s) for Cheating.`);
}
else if (action === 'copy-id') {
await navigator.clipboard.writeText(id);
button.innerHTML = '
Copied';
return;
}
else if (action === 'reset-username') {
if (!confirm(`Generate and assign a new username to account #${id}? Their display name will stay unchanged.`))
return;
const result = await adminAction(`/recnet/api/admin/accounts/${id}/username/reset`, 'POST', {});
alert(`Username reset to @${result.username}.`);
}
else if (action === 'reset-password') {
const password = prompt(`Enter a new password for account #${id}:`);
if (password === null)
return;
await adminAction(`/recnet/api/admin/accounts/${id}/password`, 'PUT', { newPassword: password });
alert('Password updated.');
return;
}
else if (action === 'delete') {
const confirmation = prompt(`This permanently deletes account #${id}. Type DELETE ${id} to confirm:`);
if (confirmation === null)
return;
await adminAction(`/recnet/api/admin/accounts/${id}`, 'DELETE', { confirmation });
}
await Promise.all([load(), loadOverview()]);
}
catch (error) {
alert(error.message);
}
finally {
button.disabled = false;
} });
list.addEventListener('click', async (e) => { const button = e.target.closest('[data-action="reset-username"],[data-action="reset-pfp"],[data-action="reset-banner"]'); if (!button)
return; e.stopImmediatePropagation(); const card = button.closest('.admin-account'), id = card.dataset.account, action = button.dataset.action; let url, message; if (action === 'reset-username') {
if (!confirm(`Generate a new username and display name for account #${id}?`))
return;
url = `/recnet/api/admin/accounts/${id}/username/reset`;
message = result => `Username and display name reset to ${result.displayName} (@${result.username}).`;
}
else if (action === 'reset-pfp') {
if (!confirm(`Reset account #${id}'s profile picture to the default PFP?`))
return;
url = `/recnet/api/admin/accounts/${id}/images/pfp/reset`;
message = () => `Profile picture reset to DefaultPFP.png.`;
}
else {
if (!confirm(`Clear account #${id}'s profile banner?`))
return;
url = `/recnet/api/admin/accounts/${id}/images/banner/reset`;
message = () => `Profile banner reset.`;
} try {
button.disabled = true;
const result = await adminAction(url, 'POST', {});
alert(message(result));
await Promise.all([load(), loadOverview()]);
}
catch (error) {
alert(error.message);
}
finally {
button.disabled = false;
} }, { capture: true });
search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 180); });
load();
loadOverview();
document.querySelector('#adminCreate').onsubmit = async (e) => { e.preventDefault(); const form = e.currentTarget, data = Object.fromEntries(new FormData(form)); const result = document.querySelector('#adminCreateResult'), button = form.querySelector('button'); button.disabled = true; button.textContent = 'Creating...'; const response = await recnetFetch('/recnet/api/admin/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), body = await response.json().catch(() => ({})); result.className = response.ok ? 'admin-result' : 'admin-result admin-error'; result.textContent = response.ok ? `Created @${body.username} as account #${body.accountId}.` : body.error || 'Account could not be created.'; button.disabled = false; button.textContent = 'Create account'; if (response.ok) {
form.reset();
load();
loadOverview();
} };
}
async function moderatorPanel() {
if (!currentUser?.isModerator || currentUser?.isDeveloper) {
app.innerHTML = '
You do not have permission to open the Moderator panel.
';
return;
}
app.innerHTML = `
Staff tools
Moderator Moderate players and room bans. Account creation, roles, balances, shop controls, room editing, ownership, and deletion stay Developer-only.
Loading moderation overview
`;
const overview = document.querySelector('#moderatorOverview'), search = document.querySelector('#moderatorSearch'), accounts = document.querySelector('#moderatorAccounts'), roomSearch = document.querySelector('#moderatorRoomSearch'), includeDorms = document.querySelector('#moderatorIncludeDorms'), roomList = document.querySelector('#moderatorRoomList'), roomEditor = document.querySelector('#moderatorRoomEditor');
let accountTimer, roomTimer, selectedRoomId = null;
const accountIdOf = entry => { const value = entry?.accountId ?? entry?.AccountId ?? entry?.player?.accountId ?? entry?.player?.AccountId; const id = Number(value); return Number.isSafeInteger(id) && id > 0 ? id : null; };
const valueOf = (obj, lower, upper) => obj?.[lower] ?? obj?.[upper];
async function moderatorAction(url, method, body = {}) { const response = await recnetFetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: method === 'GET' ? undefined : JSON.stringify(body) }), data = await response.json().catch(() => ({})); if (!response.ok)
throw new Error(data.error || data.message || 'Moderator action failed.'); return data; }
function roleBadges(person) { const roles = person.roles ?? person.Roles ?? []; return roles.length ? roles.map(role => `
${esc(role)} `).join('') : '
Player '; }
function accountMarkup(person) { const id = accountIdOf(person), roles = person.roles ?? person.Roles ?? [], developer = roles.some(role => String(role).toLowerCase() === 'developer'), ban = person.ban ?? person.Ban, lock = person.moderationLock ?? person.ModerationLock, profileImage = person.profileImage ?? person.ProfileImage ?? '/imageserver/DefaultPFP.png', displayName = person.displayName ?? person.DisplayName ?? person.username ?? person.Username ?? `Player ${id}`, username = person.username ?? person.Username ?? 'unknown'; return `
${esc(displayName)} ${ban ? ' Banned ' : ''}${lock ? ' Locked ' : ''}
@${esc(username)} · #${id} · Level ${Number(person.level ?? person.Level ?? 0)}
${roleBadges(person)}
Manage `; }
async function loadOverview() { try {
const data = await get('/recnet/api/admin/overview');
overview.innerHTML = `
${Number(data.accounts || 0).toLocaleString()} Accounts
${Number(data.admins || 0).toLocaleString()} Developers
${Number(data.moderationLocks || 0).toLocaleString()} Mod locks
${Number(data.rooms || 0).toLocaleString()} Rooms
${Number(data.photos || 0).toLocaleString()} Photos
`;
}
catch (error) {
overview.innerHTML = `
${esc(error.message)}
`;
} }
async function loadAccounts() { accounts.innerHTML = '
Loading accounts
'; try {
const data = await get('/recnet/api/admin/accounts?search=' + encodeURIComponent(search.value.trim()));
accounts.innerHTML = data.length ? data.map(accountMarkup).join('') : '
No matching accounts.
';
}
catch (error) {
accounts.innerHTML = `
${esc(error.message)}
`;
} }
accounts.addEventListener('click', async (event) => { const button = event.target.closest('[data-mod-action]'); if (!button)
return; const card = button.closest('.admin-account'), id = Number(card?.dataset.account), action = button.dataset.modAction; if (action === 'toggle') {
const tools = card.querySelector('.admin-tools');
tools.hidden = !tools.hidden;
button.textContent = tools.hidden ? 'Manage' : 'Close';
return;
} if (!Number.isSafeInteger(id))
return; try {
button.disabled = true;
if (action === 'reset-username') {
if (!confirm(`Reset account #${id}'s username and display name?`))
return;
await moderatorAction(`/recnet/api/admin/accounts/${id}/username/reset`, 'POST');
}
else if (action === 'reset-pfp') {
if (!confirm(`Reset account #${id}'s profile picture?`))
return;
await moderatorAction(`/recnet/api/admin/accounts/${id}/images/pfp/reset`, 'POST');
}
else if (action === 'reset-banner') {
if (!confirm(`Clear account #${id}'s banner?`))
return;
await moderatorAction(`/recnet/api/admin/accounts/${id}/images/banner/reset`, 'POST');
}
else if (action === 'ban') {
const reason = card.querySelector('.admin-ban-reason').value.trim(), unit = card.querySelector('.admin-ban-unit').value, amount = Number(card.querySelector('.admin-ban-amount').value), linkBan = card.querySelector('.admin-link-ban').checked;
if (reason.length < 3) {
alert('Enter a ban reason with at least 3 characters.');
return;
}
if (!confirm(`Ban account #${id}${unit === 'permanent' ? ' permanently' : ` for ${amount} ${unit}`}${linkBan ? ' and linked accounts' : ''}?`))
return;
await moderatorAction(`/recnet/api/admin/accounts/${id}/ban`, 'POST', { reason, durationAmount: unit === 'permanent' ? 1 : amount, durationUnit: unit, linkBan });
}
else if (action === 'unban') {
if (!confirm(`Unban account #${id}?`))
return;
await moderatorAction(`/recnet/api/admin/accounts/${id}/ban`, 'DELETE');
}
else if (action === 'apply-lock') {
if (!confirm(`Apply a permanent Moderation Lock to account #${id}?`))
return;
await moderatorAction(`/recnet/api/admin/accounts/${id}/moderation-lock`, 'POST', {});
}
else if (action === 'remove-lock') {
if (!confirm(`Remove the Moderation Lock from account #${id}?`))
return;
await moderatorAction(`/recnet/api/admin/accounts/${id}/moderation-lock`, 'DELETE', { removeLinkedAccounts: false });
}
await Promise.all([loadAccounts(), loadOverview()]);
}
catch (error) {
alert(error.message);
}
finally {
button.disabled = false;
} });
function roomListMarkup(room) { return `
^${esc(room.name || 'UntitledRoom')} #${room.roomId} · ${esc(room.creatorName || 'Unknown creator')} ${Number(room.banCount || 0)} room bans · ${Number(room.onlinePlayers || 0)} online${room.isDorm ? ' · Dorm' : ''} `; }
function roomBanMarkup(ban) { const id = accountIdOf(ban), player = ban.player ?? ban.Player ?? {}, name = player.displayName ?? player.DisplayName ?? player.username ?? player.Username ?? `Player ${id}`, username = player.username ?? player.Username, profile = player.profileImage ?? player.ProfileImage ?? '/imageserver/DefaultPFP.png', reason = ban.reason ?? ban.Reason ?? '', date = ban.bannedAt ?? ban.BannedAt; return `
${esc(name)} ${username ? '@' + esc(username) + ' · ' : ''}#${id}${date ? ' · ' + when(date) : ''} ${esc(reason)}
Unban
`; }
function roomEditorMarkup(room) { const summary = room.summary ?? room.Summary ?? {}, bans = room.bans ?? room.Bans ?? [], creator = room.creator ?? room.Creator ?? {}; return `
Room #${room.roomId ?? room.RoomId}
^${esc(room.name ?? room.Name ?? 'UntitledRoom')} Owned by ${esc(creator.displayName ?? creator.DisplayName ?? creator.username ?? creator.Username ?? `Player ${room.creatorAccountId ?? room.CreatorAccountId}`)}
Public page
Room bans Moderators can add and remove room bans, but cannot edit the room itself.
${bans.length} active ${bans.length ? bans.map(roomBanMarkup).join('') : '
No active room bans.
'}
`; }
async function loadRooms() { roomList.innerHTML = '
Loading rooms
'; try {
const data = await get(`/recnet/api/admin/rooms?take=100&includeDorms=${includeDorms.checked}&search=${encodeURIComponent(roomSearch.value.trim())}`);
roomList.innerHTML = data.length ? data.map(roomListMarkup).join('') : '
No matching rooms.
';
}
catch (error) {
roomList.innerHTML = `
${esc(error.message)}
`;
} }
async function openRoom(id) { selectedRoomId = Number(id); roomEditor.innerHTML = '
Loading room bans
'; try {
const room = await get(`/recnet/api/admin/rooms/${selectedRoomId}`);
roomEditor.innerHTML = roomEditorMarkup(room);
roomList.querySelectorAll('[data-moderator-room]').forEach(item => item.classList.toggle('selected', Number(item.dataset.moderatorRoom) === selectedRoomId));
}
catch (error) {
roomEditor.innerHTML = `
${esc(error.message)}
`;
} }
roomList.addEventListener('click', event => { const button = event.target.closest('[data-moderator-room]'); if (button)
openRoom(button.dataset.moderatorRoom); });
roomEditor.addEventListener('submit', async (event) => { if (event.target.id !== 'moderatorRoomBanForm')
return; event.preventDefault(); const form = event.target, data = Object.fromEntries(new FormData(form)), status = document.querySelector('#moderatorRoomStatus'), button = form.querySelector('button'); try {
button.disabled = true;
status.className = 'form-status';
status.textContent = 'Adding room ban...';
await moderatorAction(`/recnet/api/admin/rooms/${selectedRoomId}/bans`, 'POST', { accountId: Number(data.accountId), reason: data.reason });
status.textContent = 'Room ban added.';
await Promise.all([openRoom(selectedRoomId), loadRooms()]);
}
catch (error) {
status.className = 'form-status admin-error';
status.textContent = error.message;
}
finally {
button.disabled = false;
} });
roomEditor.addEventListener('click', async (event) => { const button = event.target.closest('[data-room-ban-action="remove"]'); if (!button)
return; const id = Number(button.dataset.accountId); if (!confirm(`Remove player #${id}'s room ban?`))
return; try {
button.disabled = true;
await moderatorAction(`/recnet/api/admin/rooms/${selectedRoomId}/bans/${id}`, 'DELETE');
await Promise.all([openRoom(selectedRoomId), loadRooms()]);
}
catch (error) {
alert(error.message);
}
finally {
button.disabled = false;
} });
search.addEventListener('input', () => { clearTimeout(accountTimer); accountTimer = setTimeout(loadAccounts, 180); });
roomSearch.addEventListener('input', () => { clearTimeout(roomTimer); roomTimer = setTimeout(loadRooms, 180); });
includeDorms.addEventListener('change', loadRooms);
await Promise.all([loadOverview(), loadAccounts(), loadRooms()]);
}
async function settings() {
if (!currentUser) {
app.innerHTML = '
Log in to manage your account Your profile, password, and account controls live here.
Log in ';
document.querySelector('#settingsLogin').onclick = () => loginDialog.showModal();
return;
}
app.innerHTML = '
Loading settings
';
const s = await get('/recnet/api/account/settings'), badgeData = await get('/recnet/api/account/cheer-badge');
app.innerHTML = `
`;
const badgeIcon = value => ({ 0: 'fa-thumbs-up', 10: 'fa-heart', 20: 'fa-trophy', 30: 'fa-crown', 40: 'fa-palette', 9000: 'fa-code' })[value] || 'fa-star';
document.querySelector('#passwordSettings').insertAdjacentHTML('beforebegin', `
Profile badge Choose the cheer badge displayed on your nametag.${badgeData.isDeveloper ? ' The Developer badge is available here because the 2023 in-game picker only shows its five normal slots.' : ''}
${badgeData.badges.map(badge => `${Number(badge.value) === 9000 ? 'RR ' : ` `} ${esc(badge.name)} ${Number(badge.value) === 9000 ? 'Developer access' : `${Number(badge.count).toLocaleString()} cheers`} `).join('')}
`);
const badgeSettings = document.querySelector('#badgeSettings');
badgeSettings.addEventListener('click', async (e) => { const button = e.target.closest('[data-badge]'); if (!button || button.disabled)
return; const status = badgeSettings.querySelector('.form-status'); try {
button.disabled = true;
status.textContent = 'Saving badge...';
const response = await recnetFetch('/recnet/api/account/cheer-badge', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ badge: Number(button.dataset.badge) }) }), body = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(body.error || 'Could not save badge.');
badgeSettings.querySelectorAll('[data-badge]').forEach(option => option.classList.toggle('selected', Number(option.dataset.badge) === Number(body.selectedBadge)));
status.textContent = 'Profile badge saved!';
}
catch (error) {
status.textContent = error.message;
}
finally {
button.disabled = false;
} });
const profileForm = document.querySelector('#profileSettings'), imageForm = document.querySelector('#imageSettings');
const pfpField = imageForm.querySelector('[name="profileImage"]').closest('label');
pfpField.classList.add('wide');
pfpField.innerHTML = `Profile picture
`;
const profileFile = document.querySelector('#profileFile'), profilePreview = document.querySelector('#profileUploadPreview');
profileFile.addEventListener('change', () => { const file = profileFile.files[0]; if (file)
profilePreview.src = URL.createObjectURL(file); });
const bannerField = imageForm.querySelector('[name="bannerImage"]').closest('label');
bannerField.classList.add('wide');
bannerField.innerHTML = `Banner image
`;
const bannerFile = document.querySelector('#bannerFile'), bannerPreview = document.querySelector('#bannerUploadPreview');
bannerFile.addEventListener('change', () => { const file = bannerFile.files[0]; if (file)
bannerPreview.src = URL.createObjectURL(file); });
async function saveAll(form) { const data = { ...Object.fromEntries(new FormData(profileForm)), ...Object.fromEntries(new FormData(imageForm)) }; const response = await recnetFetch('/recnet/api/account/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const body = await response.json().catch(() => ({})); const status = form.querySelector('.form-status'); status.textContent = response.ok ? 'Saved!' : body.error || 'Could not save.'; if (response.ok)
showSession(body.session); }
profileForm.onsubmit = e => { e.preventDefault(); saveAll(profileForm); };
imageForm.onsubmit = async (e) => { e.preventDefault(); const profile = profileFile.files[0], banner = bannerFile.files[0], status = imageForm.querySelector('.form-status'); if (profile) {
status.textContent = 'Uploading profile picture...';
const upload = new FormData();
upload.append('file', profile);
const response = await recnetFetch('/recnet/api/account/profile-image', { method: 'POST', body: upload }), body = await response.json().catch(() => ({}));
if (!response.ok) {
status.textContent = body.error || 'Profile upload failed.';
return;
}
imageForm.querySelector('[name="profileImage"]').value = body.path;
showSession(body.session);
} if (banner) {
status.textContent = 'Uploading banner...';
const upload = new FormData();
upload.append('file', banner);
const response = await recnetFetch('/recnet/api/account/banner-image', { method: 'POST', body: upload }), body = await response.json().catch(() => ({}));
if (!response.ok) {
status.textContent = body.error || 'Banner upload failed.';
return;
}
imageForm.querySelector('[name="bannerImage"]').value = body.path;
} await saveAll(imageForm); };
document.querySelector('#passwordSettings').onsubmit = async (e) => { e.preventDefault(); const form = e.currentTarget, response = await recnetFetch('/recnet/api/account/password', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(form))) }), body = await response.json().catch(() => ({})); form.querySelector('.form-status').textContent = response.ok ? 'Password changed!' : body.error || 'Could not change password.'; if (response.ok)
form.reset(); };
document.querySelector('#deleteAccount').onsubmit = async (e) => { e.preventDefault(); const form = e.currentTarget, data = Object.fromEntries(new FormData(form)); if (data.confirmation !== 'DELETE' || !confirm('Permanently delete this account? This cannot be undone.'))
return; const response = await recnetFetch('/recnet/api/account', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), body = await response.json().catch(() => ({})); if (response.ok) {
showSession(null);
location.hash = 'home';
}
else
form.querySelector('.form-status').textContent = body.error || 'Could not delete account.'; };
}
function select(view) { document.querySelectorAll('.nav-link').forEach(x => x.classList.toggle('active', x.dataset.view === view)); }
async function ageVerificationPage(prefillCode) {
if (!currentUser) {
app.innerHTML = '
Log in to verify your age You need to be logged in as the account that requested a code in-game.
Log in ';
document.querySelector('#ageVerificationLogin').onclick = () => loginDialog.showModal();
return;
}
app.innerHTML = `
`;
document.querySelector('#ageVerificationForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target, button = form.querySelector('button[type=submit]'), statusEl = form.querySelector('.form-status');
const file = form.elements.photo.files[0];
if (!file) { statusEl.textContent = 'Attach a photo.'; return; }
try {
button.disabled = true;
statusEl.textContent = 'Uploading...';
const body = new FormData();
body.append('code', form.elements.code.value.trim().toUpperCase());
body.append('method', form.elements.method.value);
body.append('file', file);
const r = await recnetFetch('/recnet/api/ageverification/submit', { method: 'POST', body });
const result = await r.json().catch(() => ({}));
if (!r.ok)
throw new Error(result.error || 'Could not submit your verification.');
statusEl.textContent = 'Submitted - a staff member will review it soon.';
form.querySelectorAll('input, select, button').forEach(el => el.disabled = true);
}
catch (error) {
statusEl.textContent = error.message;
button.disabled = false;
}
});
}
async function banAppeal() {
const code = new URLSearchParams(location.search).get('code') || '';
if (!currentUser) {
app.innerHTML = '
Log in to appeal your ban You need to be logged in as the banned account to submit an appeal.
Log in ';
document.querySelector('#appealLogin').onclick = () => loginDialog.showModal();
return;
}
if (!code) {
app.innerHTML = '
Missing appeal code This link is missing its appeal code. Use the link that was given to you when you were banned.
';
return;
}
app.innerHTML = '
Loading your ban details
';
let status;
try {
status = await get(`/recnet/api/banappeal/status?code=${encodeURIComponent(code)}`);
}
catch (error) {
app.innerHTML = `
Can't load this appeal ${esc(error.message)}
`;
return;
}
if (status.alreadySubmitted) {
app.innerHTML = `
Ban appeal
Appeal already submitted You already submitted an appeal for this ban on ${new Date(status.submittedAtUtc).toLocaleString()}. Sit tight - a moderator will review it.
`;
return;
}
app.innerHTML = `
Ban appeal
Appeal your ban Why you were banned ${esc(status.banReason || 'No reason was recorded.')}
${status.banIsPermanent ? 'This is a permanent ban.' : `Banned ${new Date(status.bannedAtUnix * 1000).toLocaleString()}`}
Your appeal Explain why you think this ban should be reconsidered. Be honest - staff can see your account's full history.
Appeal message Submit appeal
`;
document.querySelector('#appealForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target, button = form.querySelector('button[type=submit]'), statusEl = form.querySelector('.form-status');
try {
button.disabled = true;
statusEl.textContent = 'Submitting...';
const message = form.elements.message.value.trim();
const r = await recnetFetch('/recnet/api/banappeal/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, message }) });
const body = await r.json().catch(() => ({}));
if (!r.ok)
throw new Error(body.error || 'Could not submit your appeal.');
statusEl.textContent = 'Appeal submitted - a moderator will review it.';
form.querySelector('textarea').disabled = true;
button.remove();
}
catch (error) {
statusEl.textContent = error.message;
button.disabled = false;
}
});
}
async function route() { try {
if (location.pathname === '/recnet/banappeal') {
select('');
await banAppeal();
app.focus({ preventScroll: true });
return;
}
const hash = location.hash.slice(1);
if (hash.startsWith('user/')) {
select('');
await profile(hash.split('/')[1]);
}
else if (hash.startsWith('room/')) {
select('rooms');
await roomDetail(hash.split('/')[1]);
}
else if (hash === 'rooms') {
select('rooms');
await rooms();
}
else if (hash === 'shop') {
select('shop');
await shop();
}
else if (hash === 'settings') {
select('settings');
await settings();
}
else if (hash === 'admin' || hash === 'moderator') {
location.replace('/recnet/mocha');
return;
}
else if (hash.startsWith('ageverification')) {
select('');
await ageVerificationPage(new URLSearchParams(hash.split('?')[1] || '').get('code') || '');
}
else if (hash === 'users') {
select('users');
await users();
}
else {
select('home');
await (currentUser ? home() : landing());
}
app.focus({ preventScroll: true });
}
catch (e) {
app.innerHTML = `
This page didn’t load ${esc(e.message)}
Try again `;
document.querySelector('#routeRetry').onclick = route;
} }
document.querySelectorAll('.nav-link[data-view]').forEach(b => b.onclick = () => location.hash = b.dataset.view);
document.addEventListener('click', e => { const button = e.target.closest('.photo-open'); if (button)
openPhoto(button.dataset.photoPath); });
document.addEventListener('error', e => { const image = e.target; if (!(image instanceof HTMLImageElement) || image.dataset.fallbackApplied)
return; image.dataset.fallbackApplied = 'true'; if (image.classList.contains('avatar') || image.closest('.mini-avatar'))
image.src = '/imageserver/DefaultPFP.png';
else
image.classList.add('image-broken'); }, true);
document.querySelector('#photoClose').addEventListener('click', () => photoDialog.close());
photoDialog.addEventListener('click', e => { if (e.target === photoDialog)
photoDialog.close(); });
const globalSearch = document.querySelector('#globalSearch'), globalSearchResults = document.querySelector('#globalSearchResults');
let globalSearchTimer, globalSearchItems = [], globalSearchIndex = -1;
function closeGlobalSearch() { globalSearchResults.hidden = true; globalSearchIndex = -1; }
function updateGlobalSearchSelection() { globalSearchResults.querySelectorAll('.global-result').forEach((item, index) => item.classList.toggle('active', index === globalSearchIndex)); }
async function runGlobalSearch() {
const term = globalSearch.value.trim();
if (term.length < 2) {
closeGlobalSearch();
return;
}
globalSearchResults.hidden = false;
globalSearchResults.innerHTML = '
Searching Mocha…
';
try {
const [players, roomsFound] = await Promise.all([get('/recnet/api/users?search=' + encodeURIComponent(term)), get('/recnet/api/rooms?search=' + encodeURIComponent(term))]);
globalSearchItems = [
...players.slice(0, 4).map(player => ({ type: 'player', href: `#user/${player.accountId}`, image: player.profileImage, title: player.displayName || player.username || 'Player', meta: `@${player.username || 'unknown'} · Level ${player.level}` })),
...roomsFound.slice(0, 4).map(room => ({ type: 'room', href: `#room/${room.roomId}`, image: room.image, title: `^${room.name || 'UntitledRoom'}`, meta: `by ${room.creatorName || 'Unknown'}` }))
];
if (globalSearch.value.trim() !== term)
return;
globalSearchResults.innerHTML = globalSearchItems.length ? `${players.length ? '
Players and rooms
' : ''}${globalSearchItems.map(item => `
${item.image ? ` ` : ` `}${esc(item.title)} ${esc(item.meta)} ${item.type} `).join('')}` : '
No players or rooms match that search.
';
globalSearchIndex = -1;
}
catch (error) {
globalSearchResults.innerHTML = `
${esc(error.message)}
`;
}
}
globalSearch.addEventListener('input', () => { clearTimeout(globalSearchTimer); globalSearchTimer = setTimeout(runGlobalSearch, 180); });
globalSearch.addEventListener('focus', () => { if (globalSearch.value.trim().length >= 2)
runGlobalSearch(); });
globalSearch.addEventListener('keydown', e => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (globalSearchResults.hidden)
return runGlobalSearch();
const count = globalSearchItems.length;
if (!count)
return;
globalSearchIndex = e.key === 'ArrowDown' ? (globalSearchIndex + 1) % count : (globalSearchIndex - 1 + count) % count;
updateGlobalSearchSelection();
}
else if (e.key === 'Enter') {
e.preventDefault();
if (globalSearchIndex >= 0 && globalSearchItems[globalSearchIndex])
location.hash = globalSearchItems[globalSearchIndex].href;
else {
location.hash = 'users';
setTimeout(() => { const input = document.querySelector('.search'); if (input) {
input.value = globalSearch.value;
input.dispatchEvent(new Event('input'));
} }, 50);
}
closeGlobalSearch();
}
else if (e.key === 'Escape')
closeGlobalSearch(); });
globalSearchResults.addEventListener('click', closeGlobalSearch);
document.addEventListener('click', e => { if (!e.target.closest('.search-shell'))
closeGlobalSearch(); });
const settingsMenuButton = document.querySelector('#settingsMenuButton'), accountMenu = document.querySelector('#accountMenu');
settingsMenuButton.addEventListener('click', () => { if (!currentUser) {
loginDialog.showModal();
return;
} accountMenu.hidden = !accountMenu.hidden; });
accountMenu.addEventListener('click', e => { if (e.target.closest('.account-menu-item'))
accountMenu.hidden = true; });
document.addEventListener('click', e => { if (!e.target.closest('.account-menu-wrap'))
accountMenu.hidden = true; });
addEventListener('hashchange', route);
const loginDialog = document.querySelector('#loginDialog'), loginButton = document.querySelector('#loginButton'), loginForm = document.querySelector('#loginForm'), loginError = document.querySelector('#loginError'), miniAvatar = document.querySelector('#miniAvatar'), registerDialog = document.querySelector('#registerDialog'), registerForm = document.querySelector('#registerForm'), registerError = document.querySelector('#registerError');
function showSession(user) { currentUser = user; document.querySelector('#adminNav').hidden = !user?.isAdmin; document.querySelector('#moderatorNav').hidden = !user?.isModerator || !!user?.isDeveloper; document.querySelector('#developerNav').hidden = !user?.isDeveloper; if (user) {
loginButton.textContent = 'Log out';
miniAvatar.classList.add('has-user');
miniAvatar.style.backgroundImage = `url('${user.profileImage}')`;
miniAvatar.title = user.displayName || user.username;
}
else {
loginButton.textContent = 'Log in';
miniAvatar.classList.remove('has-user');
miniAvatar.style.backgroundImage = '';
miniAvatar.title = '';
} }
async function checkSession() { const r = await recnetFetch('/recnet/api/auth/me'); showSession(r.ok ? await r.json() : null); await route(); }
const serverStatus = document.querySelector('#serverStatus'), serverStatusLabel = document.querySelector('#serverStatusLabel');
async function refreshServerStatus() { try {
const status = await get('/recnet/api/status');
serverStatus.className = 'server-status';
serverStatusLabel.textContent = `${Number(status.onlinePlayers).toLocaleString()} online`;
serverStatus.title = `Mocha is online · ${Number(status.connectedSockets).toLocaleString()} live connections`;
}
catch {
serverStatus.className = 'server-status offline';
serverStatusLabel.textContent = 'Offline';
serverStatus.title = 'The server status could not be reached';
} }
serverStatus.addEventListener('click', () => location.hash = 'home');
miniAvatar.addEventListener('click', () => { if (currentUser)
location.hash = `user/${currentUser.accountId}`;
else
loginDialog.showModal(); });
loginButton.addEventListener('click', async () => { if (currentUser) {
await recnetFetch('/recnet/api/auth/logout', { method: 'POST' });
showSession(null);
}
else {
loginError.textContent = '';
loginDialog.showModal();
} });
document.querySelector('.dialog-close').addEventListener('click', () => loginDialog.close());
loginDialog.addEventListener('click', e => { if (e.target === loginDialog)
loginDialog.close(); });
document.querySelector('#openRegister').addEventListener('click', () => { loginDialog.close(); registerError.textContent = ''; registerDialog.showModal(); });
document.querySelector('#backToLogin').addEventListener('click', () => { registerDialog.close(); loginDialog.showModal(); });
document.querySelector('.register-close').addEventListener('click', () => registerDialog.close());
registerDialog.addEventListener('click', e => { if (e.target === registerDialog)
registerDialog.close(); });
loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const button = loginForm.querySelector('button[type=submit]'); button.disabled = true; button.textContent = 'Logging in...'; loginError.textContent = ''; const values = Object.fromEntries(new FormData(loginForm)); const r = await recnetFetch('/recnet/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }); if (r.ok) {
showSession(await r.json());
loginDialog.close();
loginForm.reset();
await route();
}
else {
const body = await r.json().catch(() => ({}));
loginError.textContent = body.error || 'Login failed.';
} button.disabled = false; button.textContent = 'Log in'; });
registerForm.addEventListener('submit', async (e) => { e.preventDefault(); const button = registerForm.querySelector('button[type=submit]'), values = Object.fromEntries(new FormData(registerForm)); button.disabled = true; button.textContent = 'Creating account...'; registerError.textContent = ''; const response = await recnetFetch('/recnet/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) }); if (response.ok) {
showSession(await response.json());
registerDialog.close();
registerForm.reset();
location.hash = 'settings';
}
else {
const body = await response.json().catch(() => ({}));
registerError.textContent = body.error || 'Account could not be created.';
} button.disabled = false; button.textContent = 'Create account'; });
refreshServerStatus();
setInterval(refreshServerStatus, 30000);
checkSession();