const recnetFetch = (input, init = {}) => fetch(input, { ...init, credentials: init.credentials ?? 'same-origin' }); const app = document.querySelector('#app'), photoDialog = document.querySelector('#photoDialog'), photoContent = document.querySelector('#photoContent'); let currentUser = null; const esc = s => String(s ?? '').replace(/[&<>'"]/g, c => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[c])); function when(value) { const date = value instanceof Date ? value : new Date(value); return Number.isNaN(date.getTime()) ? 'Unknown date' : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date); } function avatarMarkup(person) { return `${person.verified ? '' : ''}`; } function rrPlusMarkup(person) { return person.hasRRPlus ? 'RR+' : ''; } async function get(url) { const r = await recnetFetch(url, { headers: { Accept: 'application/json' } }); if (!r.ok) { const body = await r.json().catch(() => ({})); throw new Error(body.error || body.message || `Could not load this page (${r.status})`); } return r.json(); } function inlineMarkdown(value) { let text = String(value ?? ''), tokens = []; const hold = html => `MOCHATOKEN${tokens.push(html) - 1}END`; text = text.replace(/`([^`\n]+)`/g, (_, code) => hold(`${esc(code)}`)); text = text.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/gi, (_, label, url) => hold(`${esc(label)}`)); text = esc(text) .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/__([^_]+)__/g, '$1') .replace(/~~([^~]+)~~/g, '$1') .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2') .replace(/(^|[^_])_([^_\n]+)_(?!_)/g, '$1$2'); return text.replace(/MOCHATOKEN(\d+)END/g, (_, index) => tokens[Number(index)] || ''); } function markdownToHtml(value) { const lines = String(value ?? '').replace(/\r/g, '').split('\n'), html = []; let paragraph = [], listType = null, inFence = false, fenceLines = []; const flushParagraph = () => { if (paragraph.length) { html.push(`

${paragraph.join('
')}

`); paragraph = []; } }; const closeList = () => { if (listType) { html.push(``); listType = null; } }; for (const rawLine of lines) { if (/^```/.test(rawLine)) { flushParagraph(); closeList(); if (inFence) { html.push(`
${esc(fenceLines.join('\n'))}
`); fenceLines = []; inFence = false; } else inFence = true; continue; } if (inFence) { fenceLines.push(rawLine); continue; } if (!rawLine.trim()) { flushParagraph(); closeList(); continue; } const heading = rawLine.match(/^(#{1,4})\s+(.+)$/); if (heading) { flushParagraph(); closeList(); const level = heading[1].length; html.push(`${inlineMarkdown(heading[2])}`); continue; } if (/^\s*(---+|___+|\*\*\*+)\s*$/.test(rawLine)) { flushParagraph(); closeList(); html.push('
'); continue; } const quote = rawLine.match(/^>\s?(.*)$/); if (quote) { flushParagraph(); closeList(); html.push(`
${inlineMarkdown(quote[1])}
`); continue; } const unordered = rawLine.match(/^\s*[-+*]\s+(.+)$/), ordered = rawLine.match(/^\s*\d+\.\s+(.+)$/), item = unordered || ordered; if (item) { flushParagraph(); const wanted = unordered ? 'ul' : 'ol'; if (listType !== wanted) { closeList(); listType = wanted; html.push(`<${wanted}>`); } html.push(`
  • ${inlineMarkdown(item[1])}
  • `); continue; } closeList(); paragraph.push(inlineMarkdown(rawLine)); } if (inFence) html.push(`
    ${esc(fenceLines.join('\n'))}
    `); flushParagraph(); closeList(); return html.join(''); } function announcementCard(item, admin = false) { const kinds = { info: ['fa-circle-info', 'Info'], update: ['fa-wand-magic-sparkles', 'Update'], warning: ['fa-triangle-exclamation', 'Warning'], maintenance: ['fa-screwdriver-wrench', 'Maintenance'] }, kind = String(item.kind || 'info').toLowerCase(), meta = kinds[kind] || kinds.info; return `
    ${meta[1]}${item.pinned ? ' Pinned' : ''}${admin && !item.published ? 'Unpublished' : ''}

    ${esc(item.title)}

    ${markdownToHtml(item.bodyMarkdown)}
    ${admin ? `` : ''}
    `; } function photoCard(p) { const o = p.owner; return `
    ${o ? `${avatarMarkup(o)}
    ${when(p.takenAt)}
    ` : `
    Community photo
    ${when(p.takenAt)}
    `}
    `; } function featureCard(icon, title, desc) { return `

    ${title}

    ${desc}

    `; } function howStep(n, title, desc) { return `
    ${n}

    ${title}

    ${desc}

    `; } async function landing() { app.innerHTML = '
    Loading Mocha
    '; const [rooms, photos, events, status, announcements] = await Promise.all([get('/recnet/api/rooms?search=').catch(() => []), get('/recnet/api/photos/newest?take=8').catch(() => []), get('/recnet/api/events').catch(() => []), get('/recnet/api/status').catch(() => ({ status: 'online', rooms: 0, registeredPlayers: 0, onlinePlayers: 0 })), get('/recnet/api/announcements').catch(() => [])]); const previewRooms = rooms.slice(0, 6); app.innerHTML = `
    Welcome to

    Mocha

    Mocha is a community server built around the games and spaces people made. Log in with your platform account to explore player-made rooms, share photos from your sessions, and pick up new gear from the shop.

    ${Number(status.rooms || rooms.length).toLocaleString()}Rooms built
    ${Number(status.registeredPlayers || 0).toLocaleString()}Players joined
    ${Number(status.onlinePlayers || 0).toLocaleString()}Online now
    ${announcements.length ? `

    Announcements

    ${announcements.map(item => announcementCard(item)).join('')}
    ` : ''} ${events.length ? `

    Upcoming events

    ${events.map(eventCard).join('')}
    ` : ''}

    Rooms people are building

    ${previewRooms.length ? `
    ${previewRooms.map(roomCard).join('')}
    ` : '
    No rooms have been created yet.
    '}

    Fresh from the community

    ${photos.length ? `
    ${photos.map(photoCard).join('')}
    ` : '
    No photos have been uploaded yet.
    '}

    How it works

    ${howStep(1, 'Create an account', 'Link a platform ID and pick a username. Takes less than a minute.')}${howStep(2, 'Set up your profile', 'Add a profile picture, banner, bio, and cheer badge so people know it’s you.')}${howStep(3, 'Jump into rooms', 'Browse what the community has built, hang out, and share the moments worth keeping.')}

    What you can do here

    ${featureCard('fa-door-open', 'Explore rooms', 'Browse every room on the server, see who made it and how many people have visited, and jump in.')}${featureCard('fa-image', 'Share photos', 'Post photos from your sessions, cheer the ones you love, and leave comments.')}${featureCard('fa-bag-shopping', 'Shop for gear', 'Spend tokens on a daily rotation of items, from common finds to legendary drops.')}${featureCard('fa-user', 'Customize your profile', 'Set a banner, profile picture, bio, and cheer badge so your nametag stands out.')}

    Ready to jump in?

    Create an account and start exploring in under a minute.

    `; document.querySelector('#landingLogin').onclick = () => { loginError.textContent = ''; loginDialog.showModal(); }; const openRegister = () => { loginDialog.close(); registerError.textContent = ''; registerDialog.showModal(); }; document.querySelector('#landingRegister').onclick = openRegister; document.querySelector('#landingCtaRegister').onclick = openRegister; document.querySelector('#landingSeeRooms').onclick = () => location.hash = 'rooms'; document.querySelector('#landingSeePlayers').onclick = () => location.hash = 'users'; } async function home() { app.innerHTML = '
    Loading your community
    '; const [rooms, photos, events, status, announcements] = await Promise.all([get('/recnet/api/rooms?search='), get('/recnet/api/photos/newest?take=24'), get('/recnet/api/events').catch(() => []), get('/recnet/api/status'), get('/recnet/api/announcements').catch(() => [])]); const featured = rooms.slice(0, 8), name = currentUser?.displayName || currentUser?.username || 'Player'; app.innerHTML = `
    Your community

    Welcome back, ${esc(name)}!

    See what people are building, catch the newest moments, and find somewhere to jump in.

    ${Number(status.onlinePlayers).toLocaleString()}players online
    ${Number(status.onlinePlayers).toLocaleString()}Online now
    ${Number(status.rooms).toLocaleString()}Rooms
    ${Number(status.photos).toLocaleString()}Community photos
    ${Number(status.registeredPlayers).toLocaleString()}Players joined
    ${announcements.length ? `

    Announcements

    ${announcements.map(item => announcementCard(item)).join('')}
    ` : ''}

    Quick actions

    Discover a roomBrowse everything the community has built.
    Find playersOpen profiles and see their latest photos.
    Check the shopSee today’s item rotation and your balance.
    ${events.length ? `

    Upcoming events

    ${events.map(eventCard).join('')}
    ` : ''}

    Featured rooms

    ${featured.length ? `
    ${featured.map(roomCard).join('')}
    ` : '
    No rooms have been created yet.
    '}

    Latest photos

    ${photos.length ? `
    ${photos.map(photoCard).join('')}
    ` : '
    No photos have been uploaded yet.
    '}`; document.querySelector('#findPlayers').onclick = () => location.hash = 'users'; document.querySelector('#seeRooms').onclick = () => location.hash = 'rooms'; } async function users(term = '') { app.innerHTML = `
    Community

    People

    Discover players and view their latest photos.
    `; const input = app.querySelector('.search'), grid = app.querySelector('.user-grid'); let timer; async function load() { grid.innerHTML = '
    Finding players
    '; const data = await get('/recnet/api/users?search=' + encodeURIComponent(input.value)); grid.innerHTML = data.length ? data.map(u => `${avatarMarkup(u)}
    ${esc(u.displayName || u.username || 'Player')}
    ${rrPlusMarkup(u)}
    @${esc(u.username || 'unknown')} · Level ${u.level} · ${u.photoCount} photos
    ${esc(u.bio || 'No bio yet.')}
    `).join('') : '
    No players match that search.
    '; } input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 180); }); load(); } async function profile(id) { app.innerHTML = '
    Loading profile
    '; const u = await get('/recnet/api/users/' + encodeURIComponent(id)); const isOwner = currentUser && Number(currentUser.accountId) === Number(u.accountId); app.innerHTML = `

    Photos

    Newest first
    ${u.photos.length ? `
    ${u.photos.map(photoCard).join('')}
    ` : '
    This player has not uploaded any photos yet.
    '}
    `; document.querySelector('#setBannerButton')?.addEventListener('click', async () => { const suggestion = u.photos[0]?.path || ''; const image = prompt('Enter an image path, such as PlayerImages/photo.png', suggestion); if (!image) return; const form = new FormData(); form.append('bannerImage', image); const response = await recnetFetch('/acc/account/me/bannerimage', { method: 'PUT', body: form }); if (!response.ok) { alert('That banner could not be saved.'); return; } await profile(id); }); } async function openPhoto(path) { if (!photoDialog.open) photoDialog.showModal(); photoContent.innerHTML = '
    Loading photo
    '; try { const p = await get('/recnet/api/photos/detail?path=' + encodeURIComponent(path)); const comments = p.comments.map(c => c.author ? `
    ${avatarMarkup(c.author)}
    ${esc(c.author.displayName || c.author.username || 'Player')}
    ${esc(c.text)}
    ${when(c.createdAt)}
    ` : `
    Deleted player
    ${esc(c.text)}
    ${when(c.createdAt)}
    `).join(''); photoContent.innerHTML = `
    Community photo
    `; document.querySelector('#photoCheer').onclick = async () => { if (!currentUser) { alert('Log in to cheer photos.'); return; } const response = await recnetFetch('/recnet/api/photos/cheer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: p.path }) }); if (response.ok) await openPhoto(p.path); }; document.querySelector('#commentForm')?.addEventListener('submit', async (e) => { e.preventDefault(); const text = e.currentTarget.elements.text.value.trim(); if (!text) return; const response = await recnetFetch('/recnet/api/photos/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: p.path, text }) }); if (response.ok) await openPhoto(p.path); }); document.querySelector('#photoLogin')?.addEventListener('click', () => { photoDialog.close(); loginDialog.showModal(); }); } catch (error) { photoContent.innerHTML = `
    ${esc(error.message)}
    `; } } function eventCard(e) { const starts = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(e.startsAt)); return `
    ${e.image ? `` : ''}
    ${e.pinned ? ' Pinned' : ''}
    ${starts}

    ${esc(e.title)}

    ${esc(e.description || '')}

    `; } function roomCard(r) { return `
    ^${esc(r.name || 'UntitledRoom')}
    ${esc(r.description || 'No description has been added yet.')}
    by ${esc(r.creatorName)}${esc(r.accessibility)}
    ${r.stats.visits} ${r.stats.cheers} ${r.maxPlayers}
    `; } async function rooms(term = '') { app.innerHTML = `
    Discover

    Rooms

    Explore every room on this server.
    `; const input = app.querySelector('.search'), grid = app.querySelector('.room-grid'); let timer; async function load() { grid.innerHTML = '
    Loading rooms
    '; const data = await get('/recnet/api/rooms?search=' + encodeURIComponent(input.value)); grid.innerHTML = data.length ? data.map(roomCard).join('') : '
    No rooms match that search.
    '; } input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 180); }); load(); } async function roomDetail(id) { app.innerHTML = '
    Loading room
    '; const r = await get('/recnet/api/rooms/' + encodeURIComponent(id)); app.innerHTML = `
    ${esc(r.name)}
    ${r.isRRO ? 'Rec Room Original' : 'Community room'}

    ^${esc(r.name || 'UntitledRoom')}

    Created by ${esc(r.creatorName)}

    ${esc(r.description || 'No description has been added yet.')}

    ${r.tags.length ? `
    ${r.tags.map(t => `${esc(t)}`).join('')}
    ` : ''}
    ${esc(r.accessibility)}${r.supportsJuniors ? 'Junior friendly' : ''}${r.isDorm ? 'Dorm' : ''}
    ${r.stats.visits}Visits
    ${r.stats.visitors}Visitors
    ${r.stats.cheers}Cheers
    ${r.stats.favorites}Favorites

    Room details

    Max players
    ${r.maxPlayers}
    Created
    ${when(r.createdAt)}
    Status
    ${esc(r.state || 'Unknown')}
    Subrooms
    ${r.subRooms.length}
    `; } function shopFallbackIcon(name) { name = String(name || '').toLowerCase(); if (/hair|hat|helmet|cap|visor/.test(name)) return 'fa-hat-wizard'; if (/glove|gauntlet|wrist|hand/.test(name)) return 'fa-hand'; if (/glass|goggle/.test(name)) return 'fa-glasses'; if (/bow|tie|scarf|ribbon/.test(name)) return 'fa-ribbon'; if (/dress|shirt|jersey|jacket|hood|harness|robe/.test(name)) return 'fa-shirt'; return 'fa-gem'; } async function shop() { app.innerHTML = '
    Opening Mocha Shop
    '; const data = await get('/recnet/api/shop'); const rarityName = stars => ['', 'Common', 'Uncommon', 'Rare', 'Epic', 'Legendary'][stars] || 'Featured'; app.innerHTML = `
    Daily rotation

    Mocha Shop

    Fresh legendary items every day, plus anything the staff pins into the rotation.

    ${data.balance == null ? 'Log in' : Number(data.balance).toLocaleString()}${data.balance == null ? 'to purchase' : 'tokens'}

    Today’s items

    Refreshes ${when(data.nextRefresh)}
    ${data.items.length ? data.items.map(item => `
    ${item.thumbnailUrl ? `${esc(item.friendlyName)}` : ''}
    ${'★'.repeat(item.stars)}
    ${rarityName(item.stars)}

    ${esc(item.friendlyName)}

    ${Number(item.price).toLocaleString()}
    `).join('') : '
    The shop is restocking. Try again in a moment.
    '}
    `; app.querySelectorAll('.shop-image').forEach(image => image.addEventListener('error', () => { image.hidden = true; image.nextElementSibling.hidden = false; })); document.querySelector('#shopGrid').addEventListener('click', async (e) => { const button = e.target.closest('.shop-buy-button'); if (!button || button.disabled) return; if (!currentUser) { loginError.textContent = ''; loginDialog.showModal(); return; } const message = document.querySelector('#shopMessage'); try { button.disabled = true; button.innerHTML = ' Purchasing'; const response = await recnetFetch('/recnet/api/shop/purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skuId: Number(button.dataset.sku) }) }), body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || 'Purchase failed.'); button.innerHTML = ' Owned'; document.querySelector('#shopBalance').textContent = Number(body.balance).toLocaleString(); message.className = 'shop-message success'; message.textContent = body.alreadyOwned ? 'You already own that item.' : `${body.itemName} purchased!`; } catch (error) { button.disabled = false; button.innerHTML = ' Buy item'; message.className = 'shop-message error'; message.textContent = error.message; } }); } function initCommunityBoardAdmin(adminAction) { const panel = document.querySelector('#adminCommunityBoard'), saveButton = document.querySelector('#communityBoardSave'), reloadButton = document.querySelector('#communityBoardReload'), status = document.querySelector('#communityBoardStatus'), featured = document.querySelector('#communityBoardFeatured'), rooms = document.querySelector('#communityBoardRooms'), photos = document.querySelector('#communityBoardPhotos'), videos = document.querySelector('#communityBoardVideos'), dialog = document.querySelector('#communityBoardDialog'), dialogTitle = document.querySelector('#communityBoardDialogTitle'), dialogBody = document.querySelector('#communityBoardDialogBody'), dialogForm = document.querySelector('#communityBoardDialogForm'), dialogDelete = document.querySelector('#communityBoardDialogDelete'); let board = null, featuredUser = null, dirty = false, editor = { type: '', index: -1 }, previewObjectUrls = []; const setStatus = (message, error = false) => { status.className = 'community-board-status' + (error ? ' error' : ''); status.textContent = message; }; const markDirty = () => { dirty = true; saveButton.disabled = false; setStatus('Unsaved changes'); }; async function persistBoard(successMessage = 'Saved — live board updated') { saveButton.disabled = true; setStatus('Saving live board...'); try { board = normalizeBoard(await adminAction('/recnet/api/admin/community-board', 'PUT', board)); dirty = false; await loadFeaturedUser(); render(); setStatus(successMessage); return board; } catch (error) { saveButton.disabled = false; setStatus(error.message, true); throw error; } } const urlValue = value => String(value || '').trim(); const imageTile = (item, index) => ``; const isDirectVideoUrl = value => { try { return /\.(mp4|m4v|mov|webm)$/i.test(new URL(String(value || ''), location.origin).pathname); } catch { return false; } }; const youtubeVideoId = value => { try { const url = new URL(String(value || '')); if (url.hostname === 'youtu.be') return url.pathname.split('/').filter(Boolean)[0] || ''; if (url.hostname.endsWith('youtube.com')) return url.searchParams.get('v') || url.pathname.match(/\/(?:embed|shorts)\/([^/?]+)/)?.[1] || ''; } catch { } return ''; }; const videoThumbnail = item => item?.ThumbnailBlobName || ((id => id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg` : '')(youtubeVideoId(item?.SourceUrl))); const videoVisual = item => isDirectVideoUrl(item?.SourceUrl) ? `` : videoThumbnail(item) ? `` : ''; const videoTile = (item, index) => ``; const releasePreviewUrls = () => { previewObjectUrls.forEach(url => URL.revokeObjectURL(url)); previewObjectUrls = []; }; const previewUrl = file => { const url = URL.createObjectURL(file); previewObjectUrls.push(url); return url; }; function mediaPreviewMarkup(source, thumbnail, type) { if (type === 'photo') return source ? `Photo preview` : '
    Your photo preview appears here
    '; if (isDirectVideoUrl(source) || String(source || '').startsWith('blob:')) return ``; const youtubeId = youtubeVideoId(source); if (youtubeId) return `
    `; if (thumbnail) return `
    Video thumbnail
    `; return '
    Your video preview appears here
    '; } function normalizeBoard(value) { value = value && typeof value === 'object' ? value : {}; value.FeaturedPlayer = value.FeaturedPlayer && typeof value.FeaturedPlayer === 'object' ? value.FeaturedPlayer : {}; value.FeaturedRoomGroup = value.FeaturedRoomGroup && typeof value.FeaturedRoomGroup === 'object' ? value.FeaturedRoomGroup : {}; value.FeaturedRoomGroup.Rooms = Array.isArray(value.FeaturedRoomGroup.Rooms) ? value.FeaturedRoomGroup.Rooms : []; value.FeaturedRoomGroup.FeaturedRooms = Array.isArray(value.FeaturedRoomGroup.FeaturedRooms) ? value.FeaturedRoomGroup.FeaturedRooms : [...value.FeaturedRoomGroup.Rooms]; value.InstagramImages = Array.isArray(value.InstagramImages) ? value.InstagramImages.slice(0, 12) : []; value.Videos = Array.isArray(value.Videos) ? value.Videos.slice(0, 3) : []; value.CurrentAnnouncement = value.CurrentAnnouncement && typeof value.CurrentAnnouncement === 'object' ? value.CurrentAnnouncement : {}; return value; } async function loadFeaturedUser() { featuredUser = null; const id = Number(board?.FeaturedPlayer?.Id); if (!id) return; try { featuredUser = await get('/recnet/api/users/' + id); } catch { featuredUser = null; } } function render() { if (!board) return; const player = board.FeaturedPlayer || {}, pinned = board.FeaturedRoomGroup?.Rooms || [], announcement = board.CurrentAnnouncement || {}; featured.innerHTML = ``; rooms.innerHTML = `
    Pinned rooms${pinned.length} of 10

    Choose the rooms shown on the in-game community board.

    ${pinned.length ? pinned.slice(0, 4).map(room => `^${esc(room.RoomName || `Room ${room.RoomId}`)}`).join('') : 'No rooms pinned'}${pinned.length > 4 ? `+${pinned.length - 4} more` : ''}
    `; photos.innerHTML = Array.from({ length: 12 }, (_, index) => imageTile(board.InstagramImages[index], index)).join(''); videos.innerHTML = Array.from({ length: 3 }, (_, index) => videoTile(board.Videos[index], index)).join(''); document.querySelector('#communityBoardCoachPreview').textContent = announcement.Message || 'No Coach message set'; panel.querySelectorAll('.has-media img,.community-featured-button img').forEach(image => image.addEventListener('error', () => image.classList.add('is-broken'))); panel.querySelectorAll('.has-media video').forEach(video => video.addEventListener('error', () => video.classList.add('is-broken'))); } async function loadBoard() { if (!board) { board = normalizeBoard({}); render(); } setStatus('Loading live board...'); saveButton.disabled = true; reloadButton.disabled = true; try { board = normalizeBoard(await get('/recnet/api/admin/community-board')); dirty = false; panel.classList.remove('community-board-unavailable'); await loadFeaturedUser(); render(); setStatus('Live board loaded'); } catch (error) { setStatus(error.message, true); panel.classList.add('community-board-unavailable'); } finally { reloadButton.disabled = false; } } function wireMediaPreview(type) { if (type !== 'photo' && type !== 'video') return; const preview = document.querySelector('#communityMediaPreview'); if (!preview) return; const refresh = () => { releasePreviewUrls(); if (type === 'photo') { const file = dialogForm.elements.imageFile?.files?.[0], source = file ? previewUrl(file) : urlValue(dialogForm.elements.imageUrl?.value); preview.innerHTML = mediaPreviewMarkup(source, '', 'photo'); } else { const videoFile = dialogForm.elements.videoFile?.files?.[0], thumbnailFile = dialogForm.elements.thumbnailFile?.files?.[0], source = videoFile ? previewUrl(videoFile) : urlValue(dialogForm.elements.sourceUrl?.value), thumbnail = thumbnailFile ? previewUrl(thumbnailFile) : urlValue(dialogForm.elements.thumbnail?.value); preview.innerHTML = mediaPreviewMarkup(source, thumbnail, 'video'); } }; dialogBody.querySelectorAll('input[type="file"],input[data-media-url]').forEach(input => { input.addEventListener('change', refresh); input.addEventListener('input', refresh); }); refresh(); } async function uploadCommunityMedia(file, kind, fileName = file.name) { const form = new FormData(); form.append('kind', kind); form.append('file', file, fileName); const response = await recnetFetch('/recnet/api/admin/community-board/media', { method: 'POST', body: form }), data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || `${kind === 'video' ? 'Video' : 'Image'} upload failed.`); return data; } function captureVideoThumbnail(file) { return new Promise((resolve, reject) => { const video = document.createElement('video'), url = URL.createObjectURL(file); let settled = false; const finish = (error, blob) => { if (settled) return; settled = true; clearTimeout(timer); URL.revokeObjectURL(url); video.remove(); error ? reject(error) : resolve(new File([blob], `${file.name.replace(/\.[^.]+$/, '')}-thumbnail.jpg`, { type: 'image/jpeg' })); }; const draw = () => { try { const scale = Math.min(1, 1280 / video.videoWidth), canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(video.videoWidth * scale)); canvas.height = Math.max(1, Math.round(video.videoHeight * scale)); canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); canvas.toBlob(blob => blob ? finish(null, blob) : finish(new Error('Could not create a thumbnail from this video.')), 'image/jpeg', .86); } catch (error) { finish(error); } }; const timer = setTimeout(() => finish(new Error('The video preview took too long. Choose a thumbnail image manually.')), 12000); video.muted = true; video.preload = 'auto'; video.playsInline = true; video.onloadedmetadata = () => { if (!video.videoWidth || !video.videoHeight) { finish(new Error('This video cannot be previewed. Choose a thumbnail image manually.')); return; } const target = Number.isFinite(video.duration) && video.duration > 0 ? Math.min(.5, video.duration * .1) : 0; if (target > 0) { video.onseeked = draw; video.currentTime = target; } else draw(); }; video.onerror = () => finish(new Error('This browser could not read the video. Try MP4/WebM or choose a thumbnail manually.')); video.src = url; video.load(); }); } function openEditor(type, index = -1) { if (!board) return; releasePreviewUrls(); editor = { type, index }; dialogDelete.hidden = true; dialogForm.dataset.editor = type; if (type === 'featured') { const item = board.FeaturedPlayer || {}; dialogTitle.textContent = 'Change featured creator'; dialogBody.innerHTML = ``; } else if (type === 'rooms') { const items = board.FeaturedRoomGroup?.Rooms || []; dialogTitle.textContent = 'Manage pinned rooms'; dialogBody.innerHTML = `

    Add up to 10 live room IDs. Names and images can be customized for the board.

    ${Array.from({ length: 10 }, (_, i) => { const room = items[i] || {}; return `
    Slot ${i + 1}
    `; }).join('')}
    `; } else if (type === 'photo') { const item = board.InstagramImages[index] || {}; dialogTitle.textContent = `${item.ImageUrl ? 'Edit' : 'Add'} photo ${index + 1}`; dialogDelete.hidden = !item.ImageUrl; dialogBody.innerHTML = `
    or use a URL
    `; } else if (type === 'video') { const item = board.Videos[index] || {}; dialogTitle.textContent = `${item.SourceUrl ? 'Edit' : 'Add'} video ${index + 1}`; dialogDelete.hidden = !item.SourceUrl; dialogBody.innerHTML = `
    or use links
    `; } else if (type === 'coach') { const item = board.CurrentAnnouncement || {}; dialogTitle.textContent = 'Coach message all'; dialogBody.innerHTML = `

    Saving the board pushes a live Community Board refresh to connected players.

    `; } else if (type === 'maintenance') { dialogTitle.textContent = 'Scheduled maintenance countdown'; dialogBody.innerHTML = `

    This immediately updates ConfigV2 and sends the in-game maintenance notice to connected players. Use 0 to clear it.

    `; } dialog.showModal(); wireMediaPreview(type); } dialogForm.addEventListener('submit', async (event) => { event.preventDefault(); const values = Object.fromEntries(new FormData(dialogForm)); if (editor.type === 'maintenance') { const minutes = Number(values.minutes), maintenanceStatus = document.querySelector('#communityMaintenanceStatus'), submit = dialogForm.querySelector('[type="submit"]'); try { submit.disabled = true; maintenanceStatus.textContent = 'Sending notice...'; const result = await adminAction('/recnet/api/admin/maintenance', 'POST', { minutes }); maintenanceStatus.textContent = result.message; setTimeout(() => dialog.close(), 850); } catch (error) { maintenanceStatus.className = 'form-status admin-error'; maintenanceStatus.textContent = error.message; } finally { submit.disabled = false; } return; } if (editor.type === 'featured') { board.FeaturedPlayer = { ...board.FeaturedPlayer, Id: Number(values.accountId), TitleOverride: String(values.title || '').trim(), UrlOverride: urlValue(values.url) }; await loadFeaturedUser(); } else if (editor.type === 'rooms') { const pinned = []; for (let i = 0; i < 10; i++) { const roomId = Number(values[`roomId_${i}`]); if (!roomId) continue; pinned.push({ RoomName: String(values[`roomName_${i}`] || '').trim() || `Room${roomId}`, RoomId: roomId, ImageName: String(values[`roomImage_${i}`] || '').trim() }); } board.FeaturedRoomGroup.Rooms = pinned; board.FeaturedRoomGroup.FeaturedRooms = pinned.map(item => ({ ...item })); } else if (editor.type === 'photo') { const imageFile = dialogForm.elements.imageFile.files[0], uploadStatus = document.querySelector('#communityMediaUploadStatus'), submit = dialogForm.querySelector('[type="submit"]'); let imageUrl = urlValue(values.imageUrl), imageName = String(values.imageName || '').trim(); try { if (imageFile) { submit.disabled = true; uploadStatus.className = 'form-status community-upload-status'; uploadStatus.textContent = 'Uploading photo...'; const uploaded = await uploadCommunityMedia(imageFile, 'image'); imageUrl = uploaded.url; imageName = uploaded.imageName || imageName || uploaded.originalName || uploaded.fileName; } if (!imageUrl) throw new Error('Choose a photo from your PC or enter an image URL.'); } catch (error) { uploadStatus.className = 'form-status community-upload-status admin-error'; uploadStatus.textContent = error.message; submit.disabled = false; return; } finally { submit.disabled = false; } const item = { ImageName: imageName || `community-photo-${editor.index + 1}.jpg`, ImageUrl: imageUrl }; if (editor.index < board.InstagramImages.length) board.InstagramImages[editor.index] = item; else board.InstagramImages.push(item); } else if (editor.type === 'video') { const videoFile = dialogForm.elements.videoFile.files[0], chosenThumbnail = dialogForm.elements.thumbnailFile.files[0], uploadStatus = document.querySelector('#communityMediaUploadStatus'), submit = dialogForm.querySelector('[type="submit"]'); let sourceUrl = urlValue(values.sourceUrl), thumbnail = urlValue(values.thumbnail), blobName = String(values.blobName || '').trim(), thumbnailFile = chosenThumbnail; try { submit.disabled = true; if (videoFile && !thumbnailFile) { uploadStatus.textContent = 'Creating a fresh video thumbnail...'; thumbnailFile = await captureVideoThumbnail(videoFile); } const jobs = []; if (videoFile) { uploadStatus.textContent = 'Uploading video...'; jobs.push(uploadCommunityMedia(videoFile, 'video').then(uploaded => { sourceUrl = uploaded.url; blobName = uploaded.fileName; })); } if (thumbnailFile) { jobs.push(uploadCommunityMedia(thumbnailFile, 'image').then(uploaded => { thumbnail = uploaded.url; })); } await Promise.all(jobs); if (!sourceUrl) throw new Error('Choose a video from your PC or enter a video URL.'); thumbnail = thumbnail || videoThumbnail({ SourceUrl: sourceUrl }); if (!thumbnail) throw new Error('Choose a thumbnail image for this video.'); } catch (error) { uploadStatus.className = 'form-status community-upload-status admin-error'; uploadStatus.textContent = error.message; submit.disabled = false; return; } finally { submit.disabled = false; } const item = { BlobName: blobName || `community-video-${editor.index + 1}.mp4`, Title: String(values.title || '').trim(), Description: String(values.description || '').trim(), ThumbnailBlobName: thumbnail, SourceUrl: sourceUrl }; if (editor.index < board.Videos.length) board.Videos[editor.index] = item; else board.Videos.push(item); } else if (editor.type === 'coach') board.CurrentAnnouncement = { ...board.CurrentAnnouncement, Message: String(values.message || '').trim(), MoreInfoUrl: urlValue(values.moreInfoUrl) }; markDirty(); render(); const submit = dialogForm.querySelector('[type="submit"]'), uploadStatus = document.querySelector('#communityMediaUploadStatus'); try { submit.disabled = true; if (uploadStatus) { uploadStatus.className = 'form-status community-upload-status'; uploadStatus.textContent = 'Saving to the live community board...'; } await persistBoard('Saved — the live community board was updated'); dialog.close(); } catch (error) { if (uploadStatus) { uploadStatus.className = 'form-status community-upload-status admin-error'; uploadStatus.textContent = `The upload finished, but the board could not be saved: ${error.message}`; } } finally { submit.disabled = false; } }); dialogDelete.addEventListener('click', async () => { if (editor.type === 'photo') board.InstagramImages.splice(editor.index, 1); else if (editor.type === 'video') board.Videos.splice(editor.index, 1); else return; markDirty(); render(); try { dialogDelete.disabled = true; await persistBoard('Removed — the live community board was updated'); dialog.close(); } catch { } finally { dialogDelete.disabled = false; } }); dialog.querySelectorAll('[data-dialog-close]').forEach(button => button.addEventListener('click', () => dialog.close())); dialog.addEventListener('close', releasePreviewUrls); panel.addEventListener('click', event => { const button = event.target.closest('[data-board-action]'); if (!button) return; openEditor(button.dataset.boardAction, Number(button.dataset.index ?? -1)); }); saveButton.addEventListener('click', async () => { try { await persistBoard('Saved — connected players were told to refresh'); } catch { } }); reloadButton.addEventListener('click', () => { if (dirty && !confirm('Discard your unsaved community board changes?')) return; loadBoard(); }); loadBoard(); } async function adminPanel() { if (!currentUser?.isAdmin) { app.innerHTML = '
    You do not have permission to open the Admin panel.
    '; return; } app.innerHTML = `
    Move to the new Admin Panel →
    Developer tools

    Admin

    Create accounts, edit identities and platforms, manage roles, and moderate users.
    Loading server overview

    Create account

    Create a normal RR+ account. Every platform ID supports unlimited accounts.

    User management

    Search for a user, then open Manage to change their account.

    `; document.querySelector('#adminOverview').insertAdjacentHTML('afterend', `
    Access control

    Server security

    Control account creation, anonymous networks, and IP/CIDR bans from one place.

    Loading...
    Loading settings...
    IP banningEnter one IP or a CIDR range such as 203.0.113.0/24.
    Loading IP bans
    `); document.querySelector('#adminServerSettings').insertAdjacentHTML('afterend', `
    In-game live content

    Community board

    Edit the featured creator, pinned rooms, photos, videos, and Coach message.

    Loading...
    Board photosClick a photo to change it
    Board videosClick a video to change it
    Community board

    Edit item

    `); document.querySelector('#adminCommunityBoard').insertAdjacentHTML('afterend', `

    RecNet announcements

    Create posts for the RecNet home page. Markdown supports headings, bold, italics, lists, links, quotes, and code blocks.

    Live preview

    Existing announcements

    Loading announcements
    `); document.querySelector('#adminAnnouncementsPanel').insertAdjacentHTML('afterend', `

    Steam access

    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.

    Loading Steam blacklist
    `); document.querySelector('#adminSteamAccess').insertAdjacentHTML('afterend', `

    Shop controls

    Reroll the random 5-star rotation or pin catalog items into the live in-game shop.

    Custom shop items

    Loading custom items

    Add custom item

    `); document.querySelector('#adminShopPanel').insertAdjacentHTML('afterend', `
    From Coach

    Gift center

    Send a real in-game gift box to one player or every account. Gifts are always shown as sent by Coach (#1).

    From: Coach (#1)

    1. Recipient

    2. Gift

    Loading gifts
    Choose an item.

    3. Deliver

    Choose a recipient and gift.The recipient opens this through the normal in-game gift flow.
    Clear unclaimed outgoing boxesPulls back every still-pending gift box sent by Coach (#1) that no one has opened yet. Already-claimed gifts are not affected.
    `); const requiredAdminIds = ['adminServerSettings', 'adminCommunityBoard', 'adminAnnouncementsPanel', 'adminSteamAccess', 'adminShopPanel', 'adminGiftPanel']; const missingAdminIds = requiredAdminIds.filter(id => !document.getElementById(id)); if (missingAdminIds.length) throw new Error(`Admin panel markup is incomplete: ${missingAdminIds.join(', ')}`); const accountCreationToggle = document.querySelector('#adminAccountCreationEnabled'), signupToggle = document.querySelector('#adminSignupEnabled'), vpnBlockingToggle = document.querySelector('#adminVpnBlockingEnabled'), settingsStatus = document.querySelector('#adminSettingsStatus'), securityState = document.querySelector('#adminSecurityState'), ipBanForm = document.querySelector('#adminIpBanForm'), ipBanList = document.querySelector('#adminIpBanList'), ipBanStatus = document.querySelector('#adminIpBanStatus'), adminCreateForm = document.querySelector('#adminCreate'); let adminSettings = { accountCreationEnabled: true, recNetSignupEnabled: true, vpnBlockingEnabled: true }; function renderAdminSettings(settings) { adminSettings = { ...adminSettings, ...settings }; accountCreationToggle.checked = !!adminSettings.accountCreationEnabled; signupToggle.checked = !!adminSettings.recNetSignupEnabled; vpnBlockingToggle.checked = !!adminSettings.vpnBlockingEnabled; accountCreationToggle.disabled = false; signupToggle.disabled = false; vpnBlockingToggle.disabled = false; adminCreateForm.querySelectorAll('input,select,button').forEach(control => control.disabled = !adminSettings.accountCreationEnabled); securityState.className = 'security-state ' + (adminSettings.accountCreationEnabled ? 'enabled' : 'disabled'); securityState.textContent = adminSettings.accountCreationEnabled ? 'Creation open' : 'Creation locked'; const providerNote = settings.proxyCheckConfigured === false ? ' Add PROXYCHECK_API_KEY for a dedicated provider quota.' : ''; settingsStatus.className = 'form-status'; settingsStatus.textContent = `Account creation ${adminSettings.accountCreationEnabled ? 'enabled' : 'disabled'} · RecNet signup ${adminSettings.recNetSignupEnabled ? 'enabled' : 'disabled'} · VPN blocking ${adminSettings.vpnBlockingEnabled ? 'enabled' : 'disabled'}.${providerNote}`; } async function loadAdminSettings() { try { renderAdminSettings(await get('/recnet/api/admin/settings')); } catch (error) { settingsStatus.className = 'form-status admin-error'; settingsStatus.textContent = error.message; } } async function saveAdminSettings() { const requested = { accountCreationEnabled: accountCreationToggle.checked, recNetSignupEnabled: signupToggle.checked, vpnBlockingEnabled: vpnBlockingToggle.checked }; try { accountCreationToggle.disabled = signupToggle.disabled = vpnBlockingToggle.disabled = true; settingsStatus.className = 'form-status'; settingsStatus.textContent = 'Saving security settings...'; renderAdminSettings(await adminAction('/recnet/api/admin/settings', 'PUT', requested)); settingsStatus.textContent = 'Security settings saved.'; } catch (error) { renderAdminSettings(adminSettings); settingsStatus.className = 'form-status admin-error'; settingsStatus.textContent = error.message; } } accountCreationToggle.addEventListener('change', saveAdminSettings); signupToggle.addEventListener('change', saveAdminSettings); vpnBlockingToggle.addEventListener('change', saveAdminSettings); function ipBanMarkup(items) { return items.length ? items.map(item => `
    ${esc(item.network)}${esc(item.reason || 'Blocked by an administrator.')}Added ${when(item.createdAt)} by account #${Number(item.createdByAccountId) || 0}
    `).join('') : '
    No IP addresses or ranges are banned.
    '; } async function loadIpBans() { ipBanList.innerHTML = '
    Loading IP bans
    '; try { ipBanList.innerHTML = ipBanMarkup(await get('/recnet/api/admin/ip-bans')); } catch (error) { ipBanList.innerHTML = `
    ${esc(error.message)}
    `; } } ipBanForm.addEventListener('submit', async (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(ipBanForm)), button = ipBanForm.querySelector('button'); try { button.disabled = true; ipBanStatus.className = 'form-status'; ipBanStatus.textContent = 'Adding IP ban...'; await adminAction('/recnet/api/admin/ip-bans', 'POST', { network: String(data.network || '').trim(), reason: String(data.reason || '').trim() }); ipBanForm.reset(); ipBanStatus.textContent = 'IP ban added.'; await loadIpBans(); } catch (error) { ipBanStatus.className = 'form-status admin-error'; ipBanStatus.textContent = error.message; } finally { button.disabled = false; } }); ipBanList.addEventListener('click', async (event) => { const button = event.target.closest('[data-ip-ban-action="remove"]'); if (!button) return; const id = button.dataset.ipBanId, network = button.closest('.admin-ip-ban')?.querySelector('strong')?.textContent?.trim() || 'this network'; if (!confirm(`Remove the ban for ${network}?`)) return; try { button.disabled = true; await adminAction(`/recnet/api/admin/ip-bans/${encodeURIComponent(id)}`, 'DELETE', {}); ipBanStatus.className = 'form-status'; ipBanStatus.textContent = 'IP ban removed.'; await loadIpBans(); } catch (error) { ipBanStatus.className = 'form-status admin-error'; ipBanStatus.textContent = error.message; } finally { button.disabled = false; } }); loadAdminSettings(); loadIpBans(); initCommunityBoardAdmin(adminAction); const announcementForm = document.querySelector('#adminAnnouncementForm'), announcementList = document.querySelector('#adminAnnouncementList'), announcementPreview = document.querySelector('#adminAnnouncementPreview'), announcementStatus = document.querySelector('#adminAnnouncementStatus'), announcementCancel = document.querySelector('#adminAnnouncementCancel'), announcementSave = document.querySelector('#adminAnnouncementSave'); let adminAnnouncements = []; function announcementDraft() { const data = new FormData(announcementForm); return { id: Number(data.get('announcementId')) || 0, title: String(data.get('title') || 'Announcement preview'), bodyMarkdown: String(data.get('bodyMarkdown') || '*Start typing Markdown to preview it here.*'), kind: String(data.get('kind') || 'info'), pinned: data.get('pinned') === 'on', published: data.get('published') === 'on', updatedAt: new Date().toISOString(), authorName: currentUser?.displayName || currentUser?.username || 'Staff' }; } function updateAnnouncementPreview() { announcementPreview.innerHTML = announcementCard(announcementDraft()); } function resetAnnouncementForm() { announcementForm.reset(); announcementForm.elements.announcementId.value = ''; announcementForm.elements.published.checked = true; announcementSave.innerHTML = ' Create announcement'; announcementCancel.hidden = true; announcementStatus.className = 'form-status'; announcementStatus.textContent = ''; updateAnnouncementPreview(); } async function loadAnnouncements() { try { adminAnnouncements = await get('/recnet/api/admin/announcements'); announcementList.innerHTML = adminAnnouncements.length ? adminAnnouncements.map(item => announcementCard(item, true)).join('') : '
    No announcements yet.
    '; } catch (error) { announcementList.innerHTML = `
    ${esc(error.message)}
    `; } } announcementForm.addEventListener('input', updateAnnouncementPreview); announcementCancel.addEventListener('click', resetAnnouncementForm); announcementForm.addEventListener('submit', async (event) => { event.preventDefault(); const draft = announcementDraft(), editingId = Number(announcementForm.elements.announcementId.value) || 0; try { announcementSave.disabled = true; announcementStatus.className = 'form-status'; announcementStatus.textContent = editingId ? 'Saving changes...' : 'Publishing announcement...'; await adminAction(editingId ? `/recnet/api/admin/announcements/${editingId}` : '/recnet/api/admin/announcements', editingId ? 'PUT' : 'POST', { title: draft.title, bodyMarkdown: draft.bodyMarkdown, kind: draft.kind, pinned: draft.pinned, published: draft.published }); const savedMessage = editingId ? 'Announcement updated.' : 'Announcement created.'; resetAnnouncementForm(); announcementStatus.textContent = savedMessage; await loadAnnouncements(); } catch (error) { announcementStatus.className = 'form-status admin-error'; announcementStatus.textContent = error.message; } finally { announcementSave.disabled = false; } }); announcementList.addEventListener('click', async (event) => { const button = event.target.closest('[data-announcement-action]'); if (!button) return; const id = Number(button.dataset.announcementId), item = adminAnnouncements.find(entry => Number(entry.id) === id); if (!item) return; if (button.dataset.announcementAction === 'edit') { announcementForm.elements.announcementId.value = String(id); announcementForm.elements.title.value = item.title || ''; announcementForm.elements.bodyMarkdown.value = item.bodyMarkdown || ''; announcementForm.elements.kind.value = item.kind || 'info'; announcementForm.elements.pinned.checked = !!item.pinned; announcementForm.elements.published.checked = !!item.published; announcementSave.innerHTML = ' Save changes'; announcementCancel.hidden = false; announcementStatus.className = 'form-status'; announcementStatus.textContent = `Editing announcement #${id}`; updateAnnouncementPreview(); announcementForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } if (!confirm(`Delete “${item.title}”?`)) return; try { button.disabled = true; await adminAction(`/recnet/api/admin/announcements/${id}`, 'DELETE', {}); announcementStatus.className = 'form-status'; announcementStatus.textContent = 'Announcement deleted.'; if (Number(announcementForm.elements.announcementId.value) === id) resetAnnouncementForm(); await loadAnnouncements(); } catch (error) { announcementStatus.className = 'form-status admin-error'; announcementStatus.textContent = error.message; } finally { button.disabled = false; } }); resetAnnouncementForm(); loadAnnouncements(); const steamBlacklistForm = document.querySelector('#adminSteamBlacklistForm'), steamBlacklistList = document.querySelector('#adminSteamBlacklist'), steamBlacklistStatus = document.querySelector('#adminSteamBlacklistStatus'); function steamBlacklistMarkup(items) { return items.length ? items.map(item => { const actor = item.addedByDisplayName || item.addedByUsername || `Account #${item.addedByAccountId}`; return `
    ${esc(item.steamId)}${esc(item.reason || 'Blacklisted by an administrator.')} · Added by ${esc(actor)} on ${esc(when(item.addedAt ?? item.AddedAt))}
    `; }).join('') : '
    No Steam IDs are blacklisted. Every Steam player is allowed.
    '; } async function loadSteamBlacklist() { steamBlacklistList.innerHTML = '
    Loading Steam blacklist
    '; try { const items = await get('/recnet/api/admin/steam-blacklist'); steamBlacklistList.innerHTML = steamBlacklistMarkup(items); } catch (error) { steamBlacklistList.innerHTML = `
    ${esc(error.message)}
    `; } } steamBlacklistForm.addEventListener('submit', async (e) => { e.preventDefault(); const data = Object.fromEntries(new FormData(steamBlacklistForm)), button = steamBlacklistForm.querySelector('button'); if (!confirm(`Blacklist Steam ID ${data.steamId}? Future login and connect attempts will receive HTTP 403.`)) return; try { button.disabled = true; steamBlacklistStatus.className = 'form-status'; steamBlacklistStatus.textContent = 'Saving...'; await adminAction('/recnet/api/admin/steam-blacklist', 'POST', { steamId: String(data.steamId).trim(), reason: String(data.reason || '').trim() }); steamBlacklistForm.reset(); steamBlacklistStatus.textContent = 'Steam ID blacklisted.'; await loadSteamBlacklist(); } catch (error) { steamBlacklistStatus.className = 'form-status admin-error'; steamBlacklistStatus.textContent = error.message; } finally { button.disabled = false; } }); steamBlacklistList.addEventListener('click', async (e) => { const button = e.target.closest('[data-steam-action="remove"]'); if (!button) return; const steamId = button.dataset.steamId; if (!confirm(`Remove Steam ID ${steamId} from the blacklist? It will be allowed again immediately.`)) return; try { button.disabled = true; await adminAction(`/recnet/api/admin/steam-blacklist/${encodeURIComponent(steamId)}`, 'DELETE', {}); steamBlacklistStatus.className = 'form-status'; steamBlacklistStatus.textContent = 'Steam ID unblacklisted.'; await loadSteamBlacklist(); } catch (error) { steamBlacklistStatus.className = 'form-status admin-error'; steamBlacklistStatus.textContent = error.message; } finally { button.disabled = false; } }); loadSteamBlacklist(); const shopPanel = document.querySelector('#adminShopPanel'), shopStatus = document.querySelector('#adminShopStatus'), customShopItems = document.querySelector('#adminCustomShopItems'), shopSearch = document.querySelector('#adminShopSearch'), shopSearchResults = document.querySelector('#adminShopSearchResults'); let shopSearchTimer; const shopStars = rarity => ({ 0: '1 star', 10: '2 stars', 20: '3 stars', 30: '4 stars', 50: '5 stars' })[rarity] || `${rarity} rarity`; function customShopMarkup(items) { return items.length ? items.map(item => `
    ${esc(item.friendlyName)}${shopStars(item.rarity)} · SKU ${item.skuId} · ${item.price} tokens
    `).join('') : '
    No custom items pinned. All 10 slots are random 5-stars.
    '; } async function loadShopTools() { try { const shop = await get('/recnet/api/admin/shop'); customShopItems.innerHTML = customShopMarkup(shop.customItems || []); } catch (error) { customShopItems.innerHTML = `
    ${esc(error.message)}
    `; } } async function searchShopCatalog() { try { const items = await get('/recnet/api/admin/shop/catalog?take=30&search=' + encodeURIComponent(shopSearch.value.trim())); shopSearchResults.innerHTML = items.length ? items.map(item => ``).join('') : '
    No matching catalog items.
    '; } catch (error) { shopSearchResults.innerHTML = `
    ${esc(error.message)}
    `; } } shopSearch.addEventListener('input', () => { clearTimeout(shopSearchTimer); shopSearchTimer = setTimeout(searchShopCatalog, 180); }); shopPanel.addEventListener('click', async (e) => { const button = e.target.closest('[data-shop-action],#adminRefreshShop'); if (!button) return; const action = button.dataset.shopAction || 'refresh'; try { button.disabled = true; shopStatus.className = 'form-status'; shopStatus.textContent = action === 'refresh' ? 'Refreshing shop...' : action === 'add' ? 'Adding item...' : 'Removing item...'; if (action === 'refresh') { const result = await adminAction('/recnet/api/admin/shop/refresh', 'POST', {}); shopStatus.textContent = result.message || 'Shop refreshed!'; } else if (action === 'add') { await adminAction('/recnet/api/admin/shop/items', 'POST', { skuId: Number(button.dataset.sku) }); shopStatus.textContent = 'Custom item added to the live shop.'; await loadShopTools(); } else { await adminAction(`/recnet/api/admin/shop/items/${button.dataset.sku}`, 'DELETE', {}); shopStatus.textContent = 'Custom item removed from the live shop.'; await loadShopTools(); } await searchShopCatalog(); } catch (error) { shopStatus.className = 'form-status admin-error'; shopStatus.textContent = error.message; } finally { button.disabled = false; } }); loadShopTools(); searchShopCatalog(); (function initAdminGifts() { const form = document.querySelector('#adminGiftForm'), recipient = document.querySelector('#adminGiftRecipient'), sendAll = document.querySelector('#adminGiftAll'), onlineOnly = document.querySelector('#adminGiftOnlineOnly'), playerSearch = document.querySelector('#adminGiftPlayerSearch'), playerResults = document.querySelector('#adminGiftPlayerResults'), giftType = document.querySelector('#adminGiftType'), amountField = document.querySelector('#adminGiftAmountField'), amount = document.querySelector('#adminGiftAmount'), boxDesign = document.querySelector('#adminGiftDesign'), boxDesignCustom = document.querySelector('#adminGiftDesignCustom'), boxRarityField = document.querySelector('#adminGiftBoxRarityField'), boxRarity = document.querySelector('#adminGiftBoxRarity'), catalog = document.querySelector('#adminGiftCatalog'), catalogSearch = document.querySelector('#adminGiftCatalogSearch'), catalogResults = document.querySelector('#adminGiftCatalogResults'), selectedBox = document.querySelector('#adminGiftSelected'), message = document.querySelector('#adminGiftMessage'), summary = document.querySelector('#adminGiftSummary'), send = document.querySelector('#adminGiftSend'), status = document.querySelector('#adminGiftStatus'), clearOutgoing = document.querySelector('#adminGiftClearOutgoing'), clearStatus = document.querySelector('#adminGiftClearStatus'); let selectedItem = null, playerTimer, catalogTimer; const giftTypeLabel = () => ({ avatar: 'avatar item', equipment: 'equipment skin', consumable: 'consumable', box: 'level box', tokens: 'tokens', xp: 'XP' })[giftType.value] || 'gift'; const giftAmountLimits = type => type === 'tokens' ? { min: -2147483648, max: 2147483647 } : type === 'consumable' ? { min: 1, max: 100000 } : { min: 1, max: 1000000 }; function parseRecipientIds(raw) { return [...new Set((raw || '').split(/[,\s]+/).map(part => Number(part.trim())).filter(id => Number.isInteger(id) && id > 0))]; } function updateSummary() { const ids = parseRecipientIds(recipient.value), target = sendAll.checked ? (onlineOnly.checked ? 'every online player' : 'everyone') : ids.length === 1 ? `account #${ids[0]}` : ids.length > 1 ? `${ids.length} accounts (${ids.join(', ')})${onlineOnly.checked ? ' — online only' : ''}` : 'a recipient', item = giftType.value === 'tokens' ? `${Number(amount.value || 0).toLocaleString()} tokens` : giftType.value === 'xp' ? `${Number(amount.value || 0).toLocaleString()} XP` : giftType.value === 'box' ? `a rarity ${boxRarity.value} box` : selectedItem ? `${selectedItem.friendlyName}${giftType.value === 'consumable' ? ` x${Number(amount.value || 1).toLocaleString()}` : ''}` : `a ${giftTypeLabel()}`, designLabel = boxDesign.value === 'custom' ? `design #${resolveBoxDesign()}` : (GIFT_CONTEXTS.find(c => c.value === Number(boxDesign.value))?.name.replace(/_/g, ' ') || `design #${boxDesign.value}`); summary.textContent = `Coach will send ${item} to ${target} in a ${designLabel}.`; } function playerMarkup(person) { const id = accountIdOf(person), name = person.displayName ?? person.DisplayName ?? person.username ?? person.Username ?? `Player ${id}`, username = person.username ?? person.Username ?? ''; return ``; } async function searchPlayers() { const term = playerSearch.value.trim(); if (!term) { playerResults.innerHTML = ''; return; } try { const players = await get('/recnet/api/admin/accounts?search=' + encodeURIComponent(term)); playerResults.innerHTML = players.slice(0, 12).map(playerMarkup).join('') || '
    No players found.
    '; } catch (error) { playerResults.innerHTML = `
    ${esc(error.message)}
    `; } } function catalogItemMarkup(item) { const kind = item.type === 'equipment' ? 'Skin' : item.type === 'consumable' ? 'Consumable' : 'Avatar'; return ``; } async function searchGiftCatalog() { if (giftType.value === 'tokens' || giftType.value === 'xp') return; catalogResults.innerHTML = '
    Loading gift catalog
    '; try { const items = await get(`/recnet/api/admin/gifts/catalog?take=50&type=${encodeURIComponent(giftType.value)}&search=${encodeURIComponent(catalogSearch.value.trim())}`); catalogResults.innerHTML = items.length ? items.map(catalogItemMarkup).join('') : '
    No matching gifts.
    '; } catch (error) { catalogResults.innerHTML = `
    ${esc(error.message)}
    `; } } function updateGiftType() { const numbered = giftType.value === 'tokens' || giftType.value === 'xp' || giftType.value === 'consumable', limits = giftAmountLimits(giftType.value), current = Number(amount.value); amountField.hidden = !numbered; amount.min = String(limits.min); amount.max = String(limits.max); if (!Number.isInteger(current) || current < limits.min || current > limits.max || (giftType.value === 'tokens' && current === 0)) amount.value = '1'; boxRarityField.hidden = giftType.value !== 'box'; catalog.hidden = giftType.value === 'tokens' || giftType.value === 'xp' || giftType.value === 'box'; selectedItem = null; selectedBox.className = 'admin-gift-selected empty'; selectedBox.textContent = giftType.value === 'box' ? 'Random consumable rolled per recipient.' : catalog.hidden ? giftType.value === 'tokens' ? 'Signed token gift — negative amounts deduct tokens.' : 'XP amount gift' : 'Choose an item.'; if (!catalog.hidden) searchGiftCatalog(); updateSummary(); } playerSearch.addEventListener('input', () => { clearTimeout(playerTimer); playerTimer = setTimeout(searchPlayers, 180); }); playerResults.addEventListener('click', event => { const button = event.target.closest('[data-gift-player]'); if (!button) return; const existing = parseRecipientIds(recipient.value), id = Number(button.dataset.giftPlayer); recipient.value = existing.includes(id) ? existing.join(', ') : [...existing, id].join(', '); sendAll.checked = false; recipient.disabled = false; playerSearch.value = ''; playerResults.innerHTML = ''; updateSummary(); }); sendAll.addEventListener('change', () => { recipient.disabled = sendAll.checked; playerSearch.disabled = sendAll.checked; if (sendAll.checked) playerResults.innerHTML = ''; updateSummary(); }); onlineOnly.addEventListener('change', updateSummary); recipient.addEventListener('input', updateSummary); giftType.addEventListener('change', updateGiftType); 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 }; }); })(); (function populateGiftContexts() { const customOption = boxDesign.querySelector('option[value="custom"]'); for (const { name, value } of GIFT_CONTEXTS) { const opt = document.createElement('option'); opt.value = String(value); opt.textContent = `${name} (${value})`; boxDesign.insertBefore(opt, customOption); } boxDesign.querySelector('option[value="2"]').remove(); boxDesign.querySelector('option[value="110000"]').remove(); boxDesign.value = '2'; })(); boxRarity.addEventListener('change', updateSummary); function resolveBoxDesign() { return boxDesign.value === 'custom' ? Number(boxDesignCustom.value || 0) : Number(boxDesign.value); } boxDesign.addEventListener('change', () => { boxDesignCustom.hidden = boxDesign.value !== 'custom'; if (boxDesign.value === 'custom') boxDesignCustom.focus(); updateSummary(); }); boxDesignCustom.addEventListener('input', updateSummary); amount.addEventListener('input', updateSummary); catalogSearch.addEventListener('input', () => { clearTimeout(catalogTimer); catalogTimer = setTimeout(searchGiftCatalog, 180); }); catalogResults.addEventListener('click', event => { const button = event.target.closest('[data-gift-sku]'); if (!button) return; const rows = [...catalogResults.querySelectorAll('[data-gift-sku]')]; rows.forEach(row => row.classList.toggle('selected', row === button)); selectedItem = { skuId: Number(button.dataset.giftSku), friendlyName: button.querySelector('strong')?.textContent || 'Selected item' }; selectedBox.className = 'admin-gift-selected'; selectedBox.innerHTML = `${esc(selectedItem.friendlyName)}SKU ${selectedItem.skuId}`; updateSummary(); }); form.addEventListener('submit', async event => { event.preventDefault(); const type = giftType.value, needsItem = !['tokens', 'xp', 'box'].includes(type), numericAmount = Number(amount.value), limits = giftAmountLimits(type), ids = parseRecipientIds(recipient.value); if (!sendAll.checked && ids.length === 0) { status.className = 'form-status admin-error'; status.textContent = 'Choose a recipient, or paste a comma-separated list of account IDs.'; return; } if (needsItem && !selectedItem) { status.className = 'form-status admin-error'; status.textContent = 'Choose an item from the catalog.'; return; } if (!Number.isInteger(numericAmount) || numericAmount < limits.min || numericAmount > limits.max || (type === 'tokens' && numericAmount === 0)) { status.className = 'form-status admin-error'; status.textContent = type === 'tokens' ? 'Enter a non-zero whole token amount from -2,147,483,648 to 2,147,483,647.' : `Enter a whole amount from ${limits.min.toLocaleString()} to ${limits.max.toLocaleString()}.`; return; } if (sendAll.checked && !confirm(`Send this gift from Coach (#1) to EVERY${onlineOnly.checked ? ' ONLINE' : ''} player account?`)) return; if (!sendAll.checked && ids.length > 1 && !confirm(`Send this gift from Coach (#1) to ${ids.length} accounts: ${ids.join(', ')}?`)) return; try { send.disabled = true; status.className = 'form-status'; status.textContent = sendAll.checked ? `Queuing gifts for every${onlineOnly.checked ? ' online' : ''} player...` : `Sending gift to ${ids.length} account${ids.length === 1 ? '' : 's'}...`; const result = await adminAction('/recnet/api/admin/gifts', 'POST', { recipientAccountId: ids[0] || 0, recipientAccountIds: ids, sendToAll: sendAll.checked, onlineOnly: onlineOnly.checked, giftType: type, skuId: selectedItem?.skuId || 0, amount: numericAmount, boxRarity: Number(boxRarity.value), boxDesign: resolveBoxDesign(), message: message.value.trim() }); const live = Number(result.livePlayers) || 0, pending = Number(result.pendingPlayers) || 0; status.textContent = `Sent ${result.gift} from Coach to ${result.queued} player${result.queued === 1 ? '' : 's'} — ${live} live now${pending ? `, ${pending} queued for fallback delivery` : ''}${result.failed ? ` (${result.failed} failed)` : ''}.`; } catch (error) { status.className = 'form-status admin-error'; status.textContent = error.message; } finally { send.disabled = false; } }); clearOutgoing.addEventListener('click', async () => { if (!confirm('Clear every unclaimed outgoing gift box sent by Coach (#1)? Boxes already opened by players are not affected.')) return; try { clearOutgoing.disabled = true; clearStatus.className = 'form-status'; clearStatus.textContent = 'Clearing unclaimed boxes...'; const result = await adminAction('/recnet/api/admin/gifts/clear-outgoing', 'POST', { fromPlayerId: 1 }); clearStatus.textContent = `Removed ${result.removedBoxes} unclaimed box${result.removedBoxes === 1 ? '' : 'es'} across ${result.affectedPlayers} player${result.affectedPlayers === 1 ? '' : 's'}.`; } catch (error) { clearStatus.className = 'form-status admin-error'; clearStatus.textContent = error.message; } finally { clearOutgoing.disabled = false; } }); message.addEventListener('input', updateSummary); updateGiftType(); })(); document.querySelector('#adminShopPanel').insertAdjacentHTML('afterend', `

    Room management

    Edit room settings, stats, RoomRoles, subrooms, RoomBlob/metadata pairs, ownership, room bans, and import full room ZIP/JSON exports.

    Pick a room to manage it.
    `); (function initAdminRooms() { const panel = document.querySelector('#adminRoomsPanel'), search = document.querySelector('#adminRoomSearch'), includeDorms = document.querySelector('#adminRoomIncludeDorms'), list = document.querySelector('#adminRoomList'), editor = document.querySelector('#adminRoomEditor'), refresh = document.querySelector('#adminRoomRefresh'), importButton = document.querySelector('#adminRoomImport'), importFile = document.querySelector('#adminRoomImportFile'), importStatus = document.querySelector('#adminRoomImportStatus'); let selectedRoomId = null, searchTimer; const assignedRoles = ['None', 'Host', 'Moderator', 'CoOwner', 'TemporaryCoOwner', 'Banned']; const invitedRoles = ['None', 'Host', 'Moderator', 'CoOwner', 'TemporaryCoOwner']; const roomStates = ['Active', 'PendingJunior', 'Moderation_PendingReview', 'Moderation_Closed', 'MarkedForDelete']; const accessibilities = ['Private', 'Public', 'Unlisted']; const flagLabels = { 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 optionMarkup = (values, current) => values.map(value => ``).join(''); const accountIdOf = entry => { const value = entry?.accountId ?? entry?.AccountId ?? entry?.player?.accountId ?? entry?.player?.AccountId; const accountId = Number(value); return Number.isSafeInteger(accountId) && accountId > 0 ? accountId : null; }; const playerName = entry => entry?.player?.displayName || entry?.player?.DisplayName || entry?.player?.username || entry?.player?.Username || (accountIdOf(entry) ? `Player ${accountIdOf(entry)}` : 'Unknown player'); const playerHandle = entry => { const username = entry?.player?.username ?? entry?.player?.Username; return username ? `@${username}` : (accountIdOf(entry) ? `#${accountIdOf(entry)}` : '#unknown'); }; function roomSummaryMarkup(room) { return ``; } function roleRow(role, room) { const accountId = accountIdOf(role), roleName = role?.role ?? role?.Role ?? 'None', invitedRole = role?.invitedRole ?? role?.InvitedRole ?? 'None', profileImage = role?.player?.profileImage ?? role?.player?.ProfileImage ?? '', creator = accountId === Number(room.creatorAccountId) || roleName === 'Creator'; return `
    ${esc(playerName(role))}${esc(playerHandle(role))}${accountId ? ` · #${accountId}` : ''}
    ${esc(roleName)}${invitedRole && invitedRole !== 'None' ? `Invited: ${esc(invitedRole)}` : ''}
    ${!accountId ? 'Missing account ID' : creator ? 'Owner' : ``}
    `; } function subRoomRow(sub) { const roomBlobOk = !!sub.roomBlobExists, metadataBlobOk = !!sub.metadataBlobExists; return `
    ${esc(sub.name)}#${sub.subRoomId} · Save #${sub.currentSaveId || 0}${sub.hasData ? ' · Has data' : ''}
    ${esc(sub.accessibility)}
    Persistence blob pairThese files must already exist in CDN/room. Saving creates a new active persistence save.
    ${roomBlobOk && metadataBlobOk ? 'Files found' : 'Missing file'}
    RoomBlob ${roomBlobOk ? 'exists' : 'not found'} Metadata ${metadataBlobOk ? 'exists' : 'not found'}
    `; } function banRow(ban) { const accountId = accountIdOf(ban), username = ban?.player?.username ?? ban?.player?.Username, profileImage = ban?.player?.profileImage ?? ban?.player?.ProfileImage ?? '', reason = ban?.reason ?? ban?.Reason ?? '', bannedAt = ban?.bannedAt ?? ban?.BannedAt; return `
    ${esc(playerName(ban))}${username ? '@' + esc(username) + ' · ' : ''}${accountId ? `#${accountId}` : '#unknown'} · ${when(bannedAt)}

    ${esc(reason)}

    ${accountId ? `` : 'Missing account ID'}
    `; } function roomEditorMarkup(room) { const flags = Object.entries(flagLabels).map(([key, label]) => ``).join(''); return `
    Room #${room.roomId}

    ^${esc(room.name)}

    Owned by ${esc(room.creator?.displayName || room.creator?.username || `Player ${room.creatorAccountId}`)} (#${room.creatorAccountId}) · Version ${room.version}

    Public page

    Room settings

    Core metadata, accessibility, state, tags, and compatibility flags.

    ${flags}

    Room stats

    Directly change the counters returned by the room APIs.

    RoomRoles

    Add, update, invite, or remove a player’s role.

    ${room.roles.length} entries
    ${room.roles.length ? room.roles.map(role => roleRow(role, room)).join('') : '
    No RoomRoles assigned.
    '}

    Transfer ownership

    Developer-only. The old creator becomes CoOwner.

    Subrooms

    Create, clone, modify, or delete room subrooms.

    ${room.subRooms.length} subrooms
    ${room.subRooms.map(subRoomRow).join('')}

    Room bans

    Ban or unban players from this room.

    ${room.bans.length} active
    ${room.bans.length ? room.bans.map(banRow).join('') : '
    No active room bans.
    '}
    `; } function setStatus(message, error = false) { const status = document.querySelector('#adminRoomStatus'); if (!status) return; status.className = error ? 'form-status admin-error' : 'form-status'; status.textContent = message; } async function loadRooms() { list.innerHTML = '
    Loading rooms
    '; try { const rooms = await get(`/recnet/api/admin/rooms?take=250&includeDorms=${includeDorms.checked ? 'true' : 'false'}&search=${encodeURIComponent(search.value.trim())}`); list.innerHTML = rooms.length ? rooms.map(roomSummaryMarkup).join('') : '
    No rooms match that search.
    '; } catch (error) { list.innerHTML = `
    ${esc(error.message)}
    `; } } async function openRoom(roomId, successMessage = '') { selectedRoomId = Number(roomId); editor.innerHTML = '
    Loading room editor
    '; try { const room = await get(`/recnet/api/admin/rooms/${selectedRoomId}`); editor.innerHTML = roomEditorMarkup(room); if (successMessage) setStatus(successMessage); await loadRooms(); } catch (error) { editor.innerHTML = `
    ${esc(error.message)}
    `; } } async function mutate(url, method, body, message) { setStatus('Saving...'); try { await adminAction(url, method, body); await openRoom(selectedRoomId, message); } catch (error) { setStatus(error.message, true); throw error; } } function importedRoomDocuments(payload) { if (Array.isArray(payload)) return payload; if (payload && typeof payload === 'object') { if (Array.isArray(payload.Rooms)) return payload.Rooms; if (Array.isArray(payload.rooms)) return payload.rooms; return [payload]; } throw new Error('The file must contain a room object, an array of rooms, or an object with a Rooms array.'); } function setImportStatus(message, error = false) { importStatus.className = error ? 'form-status admin-room-import-status admin-error' : 'form-status admin-room-import-status'; importStatus.textContent = message; } async function sendRoomImport(payload, overwrite = false) { const response = await recnetFetch(`/recnet/api/admin/rooms/import?overwrite=${overwrite ? 'true' : 'false'}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }), data = await response.json().catch(() => ({})); if (response.status === 409 && !overwrite) { const conflicts = Array.isArray(data.conflictingRoomIds) && data.conflictingRoomIds.length ? `\n\nExisting IDs: ${data.conflictingRoomIds.map(id => '#' + id).join(', ')}` : ''; if (confirm(`${data.error || 'One or more rooms already exist.'}${conflicts}\n\nOverwrite the existing room data?`)) return sendRoomImport(payload, true); } if (!response.ok) throw new Error(data.error || 'Room import failed.'); return data; } async function sendRoomArchive(file, replaceExisting = true) { const form = new FormData(); form.append('file', file, file.name); form.append('replaceExisting', replaceExisting ? 'true' : 'false'); const response = await recnetFetch('/recnet/api/admin/rooms/import', { method: 'POST', body: form }), data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.detail || data.error || 'Room ZIP import failed.'); return data; } importButton.addEventListener('click', () => importFile.click()); importFile.addEventListener('change', async () => { const file = importFile.files?.[0]; if (!file) return; try { importButton.disabled = true; setImportStatus(`Reading ${file.name}...`); const lowerName = file.name.toLowerCase(); const isZip = lowerName.endsWith('.zip') || file.type.includes('zip'); if (isZip) { if (!confirm(`Import the full room archive ${file.name}? This replaces an existing room with the same RoomId and installs its blobs, image, baked metadata, and asset bundles.`)) return; setImportStatus(`Uploading and importing ${file.name}...`); const result = await sendRoomArchive(file, true); const versions = Array.isArray(result.unityEngineVersions) && result.unityEngineVersions.length ? ` · Unity ${result.unityEngineVersions.join(', ')}` : ''; const message = `Imported room #${result.roomId}: ${result.subRoomsImported || 0} subrooms, ${result.savesImported || 0} saves, ${result.bakedAssetsImported || 0} baked entries, ${result.assetBundlesCopied || 0} bundles copied${result.assetBundlesMissing ? `, ${result.assetBundlesMissing} missing` : ''}${versions}.`; setImportStatus(message, Number(result.assetBundlesMissing || 0) > 0); await loadRooms(); if (Number.isSafeInteger(Number(result.roomId)) && Number(result.roomId) > 0) await openRoom(Number(result.roomId), message); return; } const text = await file.text(); let payload; try { payload = JSON.parse(text); } catch (error) { throw new Error(`Invalid JSON: ${error.message}`); } const documents = importedRoomDocuments(payload); if (!documents.length) throw new Error('The file does not contain any rooms.'); const roomNames = documents.slice(0, 3).map(room => room?.Name || room?.name || `Room #${room?.RoomId ?? room?.roomId ?? 'new'}`).join(', '), more = documents.length > 3 ? ` and ${documents.length - 3} more` : ''; if (!confirm(`Import ${documents.length} room${documents.length === 1 ? '' : 's'} from ${file.name}?\n\n${roomNames}${more}`)) return; setImportStatus(`Importing ${documents.length} room${documents.length === 1 ? '' : 's'}...`); const result = await sendRoomImport(payload); const message = `Imported ${result.importedCount} room${result.importedCount === 1 ? '' : 's'}: ${result.createdCount} new, ${result.updatedCount} replaced.`; setImportStatus(message); await loadRooms(); const firstRoomId = Number(result.rooms?.[0]?.roomId ?? result.rooms?.[0]?.RoomId); if (Number.isSafeInteger(firstRoomId) && firstRoomId > 0) await openRoom(firstRoomId, message); } catch (error) { setImportStatus(error.message, true); console.error(error); } finally { importButton.disabled = false; importFile.value = ''; } }); list.addEventListener('click', e => { const button = e.target.closest('[data-room-id]'); if (button) openRoom(button.dataset.roomId); }); search.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(loadRooms, 180); }); includeDorms.addEventListener('change', loadRooms); refresh.addEventListener('click', async () => { refresh.disabled = true; await loadRooms(); if (selectedRoomId) await openRoom(selectedRoomId); refresh.disabled = false; }); editor.addEventListener('submit', async (e) => { e.preventDefault(); if (!selectedRoomId) return; const form = e.target; try { if (form.id === 'adminRoomGeneralForm') { const values = Object.fromEntries(new FormData(form)); const body = { name: values.name, description: values.description, imageName: values.imageName, accessibility: values.accessibility, state: values.state, maxPlayers: Number(values.maxPlayers), minLevel: Number(values.minLevel), tags: String(values.tags || '').split(',').map(tag => tag.trim()).filter(Boolean) }; Object.keys(flagLabels).forEach(key => body[key] = form.elements[key].checked); await mutate(`/recnet/api/admin/rooms/${selectedRoomId}`, 'PUT', body, 'Room settings saved.'); } else if (form.id === 'adminRoomStatsForm') { const values = Object.fromEntries(new FormData(form)); await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/stats`, 'PUT', { cheers: Number(values.cheers), favorites: Number(values.favorites), visitors: Number(values.visitors), visits: Number(values.visits) }, 'Room stats saved.'); } else if (form.id === 'adminRoomRoleForm') { const values = Object.fromEntries(new FormData(form)); await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/roles/${Number(values.accountId)}`, 'PUT', { role: values.role, invitedRole: values.invitedRole }, 'RoomRole saved.'); } else if (form.id === 'adminRoomOwnerForm') { const accountId = Number(new FormData(form).get('accountId')); if (!confirm(`Transfer room #${selectedRoomId} to account #${accountId}?`)) return; await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/transfer-owner`, 'POST', { accountId }, 'Room ownership transferred.'); } else if (form.id === 'adminSubRoomCreateForm') { const name = String(new FormData(form).get('name') || '').trim(); await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/subrooms`, 'POST', { name }, 'Subroom created.'); } else if (form.id === 'adminRoomBanForm') { const values = Object.fromEntries(new FormData(form)); await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/bans`, 'POST', { accountId: Number(values.accountId), reason: values.reason }, 'Player banned from room.'); } } catch (error) { console.error(error); } }); editor.addEventListener('click', async (e) => { const button = e.target.closest('[data-room-action],#adminRoomRoleCancel'); if (!button || !selectedRoomId) return; const action = button.dataset.roomAction; if (button.id === 'adminRoomRoleCancel') { const form = document.querySelector('#adminRoomRoleForm'); form.reset(); form.elements.accountId.readOnly = false; button.hidden = true; return; } try { button.disabled = true; if (action === 'export') { const response = await recnetFetch(`/recnet/api/admin/rooms/${selectedRoomId}/export`, { method: 'GET' }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'Room export failed.'); } const blob = await response.blob(); const disposition = response.headers.get('Content-Disposition') || ''; const match = /filename="?([^";]+)"?/i.exec(disposition); const fileName = match ? match[1] : `room-${selectedRoomId}-backup.zip`; const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); setStatus('Backup downloaded.'); } else if (action === 'edit-role') { const form = document.querySelector('#adminRoomRoleForm'); form.elements.accountId.value = button.dataset.accountId; form.elements.accountId.readOnly = true; form.elements.role.value = button.dataset.role; form.elements.invitedRole.value = button.dataset.invitedRole; document.querySelector('#adminRoomRoleCancel').hidden = false; form.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } else if (action === 'remove-role') { const accountId = Number(button.dataset.accountId); if (!Number.isSafeInteger(accountId) || accountId < 1) throw new Error('This RoomRole is missing a valid account ID. Refresh the room and try again.'); if (!confirm(`Remove account #${accountId}'s RoomRole?`)) return; await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/roles/${accountId}`, 'DELETE', {}, 'RoomRole removed.'); } else if (action === 'save-subroom') { const card = button.closest('[data-subroom-id]'), subRoomId = Number(card.dataset.subroomId); await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/subrooms/${subRoomId}`, 'PUT', { name: card.querySelector('[data-subroom-field="name"]').value, maxPlayers: Number(card.querySelector('[data-subroom-field="maxPlayers"]').value), accessibility: card.querySelector('[data-subroom-field="accessibility"]').value, isSandbox: card.querySelector('[data-subroom-field="isSandbox"]').checked, unitySceneId: card.querySelector('[data-subroom-field="unitySceneId"]').value }, 'Subroom saved.'); } else if (action === 'save-blobs') { const card = button.closest('[data-subroom-id]'), subRoomId = Number(card.dataset.subroomId), roomBlob = card.querySelector('[data-subroom-field="roomBlob"]').value.trim(), metadataBlob = card.querySelector('[data-subroom-field="metadataBlob"]').value.trim(); if (!roomBlob || !metadataBlob) throw new Error('Enter both the RoomBlob and metadata blob filenames.'); if (roomBlob.toLowerCase() === metadataBlob.toLowerCase()) throw new Error('RoomBlob and metadata blob must be different files.'); if (!confirm(`Change the active blob pair for room #${selectedRoomId}, subroom #${subRoomId}?\n\nRoomBlob: ${roomBlob}\nMetadata: ${metadataBlob}`)) return; await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/subrooms/${subRoomId}/blobs`, 'PUT', { roomBlob, metadataBlob }, 'RoomBlob and metadata blob saved.'); } else if (action === 'clone-subroom') { const subRoomId = Number(button.closest('[data-subroom-id]').dataset.subroomId); await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/subrooms/${subRoomId}/clone`, 'POST', {}, 'Subroom cloned.'); } else if (action === 'delete-subroom') { const subRoomId = Number(button.closest('[data-subroom-id]').dataset.subroomId); if (!confirm(`Delete subroom #${subRoomId} and its saved data?`)) return; await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/subrooms/${subRoomId}`, 'DELETE', {}, 'Subroom deleted.'); } else if (action === 'remove-ban') { const accountId = Number(button.dataset.accountId); if (!Number.isSafeInteger(accountId) || accountId < 1) throw new Error('This room ban is missing a valid account ID. Refresh the room and try again.'); if (!confirm(`Unban account #${accountId} from this room?`)) return; await mutate(`/recnet/api/admin/rooms/${selectedRoomId}/bans/${accountId}`, 'DELETE', {}, 'Room ban removed.'); } } catch (error) { console.error(error); } finally { button.disabled = false; } }); loadRooms(); })(); document.querySelector('#adminShopPanel').insertAdjacentHTML('afterend', `

    Events

    Post scheduled events. Pinned events always show first on the homepage.

    `); (function initEvents() { const eventForm = document.querySelector('#eventForm'), eventList = document.querySelector('#eventList'), eventStatus = document.querySelector('#eventStatus'), eventCancel = document.querySelector('#eventCancel'), eventSubmit = document.querySelector('#eventSubmit'); function toLocalInput(iso) { if (!iso) return ''; const d = new Date(iso); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function resetEventForm() { eventForm.reset(); eventForm.elements.eventId.value = ''; eventSubmit.textContent = 'Create event'; eventCancel.hidden = true; } function eventListItem(e) { return `
    `; } async function loadEvents() { eventList.innerHTML = '
    Loading events
    '; try { const events = await get('/recnet/api/admin/events'); eventList.innerHTML = events.length ? events.map(eventListItem).join('') : '
    No events yet.
    '; } catch (error) { eventList.innerHTML = `
    ${esc(error.message)}
    `; } } eventForm.addEventListener('submit', async (e) => { e.preventDefault(); const values = Object.fromEntries(new FormData(eventForm)), id = values.eventId; const body = { title: values.title, description: values.description, imageName: values.imageName, startsAt: values.startsAt ? new Date(values.startsAt).toISOString() : null, endsAt: values.endsAt ? new Date(values.endsAt).toISOString() : null, pinned: eventForm.elements.pinned.checked }; eventStatus.className = 'form-status'; eventStatus.textContent = id ? 'Saving...' : 'Creating...'; try { eventSubmit.disabled = true; await adminAction(id ? `/recnet/api/admin/events/${id}` : '/recnet/api/admin/events', id ? 'PUT' : 'POST', body); eventStatus.textContent = id ? 'Event updated!' : 'Event created!'; resetEventForm(); await loadEvents(); } catch (error) { eventStatus.className = 'form-status admin-error'; eventStatus.textContent = error.message; } finally { eventSubmit.disabled = false; } }); eventList.addEventListener('click', async (e) => { const button = e.target.closest('[data-action]'); if (!button) return; const id = button.dataset.id; if (button.dataset.action === 'delete-event') { if (!confirm('Delete this event?')) return; try { button.disabled = true; await adminAction(`/recnet/api/admin/events/${id}`, 'DELETE', {}); await loadEvents(); } catch (error) { alert(error.message); } } else if (button.dataset.action === 'edit-event') { try { const events = await get('/recnet/api/admin/events'), target = events.find(x => String(x.id) === String(id)); if (!target) return; eventForm.elements.eventId.value = target.id; eventForm.elements.title.value = target.title; eventForm.elements.description.value = target.description || ''; eventForm.elements.imageName.value = target.image ? decodeURIComponent(target.image.replace('/imageserver/', '')) : ''; eventForm.elements.startsAt.value = toLocalInput(target.startsAt); eventForm.elements.endsAt.value = target.endsAt ? toLocalInput(target.endsAt) : ''; eventForm.elements.pinned.checked = !!target.pinned; eventSubmit.textContent = 'Save event'; eventCancel.hidden = false; eventForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (error) { alert(error.message); } } }); eventCancel.addEventListener('click', resetEventForm); loadEvents(); })(); const search = document.querySelector('#adminSearch'), list = document.querySelector('#adminAccounts'); let timer; const roles = ['Screenshare', 'Moderator', 'Developer', 'Influencer', 'RRPlus', 'Keepsake']; const platforms = ['Steam', 'Oculus', 'PlayStation', 'Xbox', 'IOS', 'GooglePlay']; function accountCard(a) { return `
    ${a.roles.map(r => `${esc(r)}`).join('')}
    `; } function moderationAccountCard(a) { let html = accountCard(a); const start = html.indexOf('
    '), end = html.indexOf('
    ', start); let moderation; if (a.moderationLock) { moderation = `

    Moderation Lock

    Moderation Lock active
    Moderation Lock
    32-bit maximum duration · Issued ${when(a.moderationLock.issuedAt)}
    `; } else if (a.ban) { moderation = `

    Ban

    Player is banned
    ${esc(a.ban.reason)}
    Issued ${when(a.ban.issuedAt)}
    `; html = html.replace('
    `; return resettableAccountCard(a).replace('
    ', inventory + '
    '); } function inventoryAvatarRow(item) { const name = item.friendlyName || item.avatarItemDesc || `Avatar item ${item.avatarItemId}`; return `
    ${esc(name)}${esc(item.avatarItemDesc || '')}${item.avatarItemId ? ` · #${item.avatarItemId}` : ''}
    `; } function inventoryConsumableRow(item) { const name = item.friendlyName || item.consumableItemDesc || `Consumable ${item.consumableItemId}`; return `
    ${esc(name)}${esc(item.consumableItemDesc || '')}${item.consumableItemId ? ` · #${item.consumableItemId}` : ''}
    `; } 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.
    '}
    ConsumablesSet 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

    Player moderation

    Ban players, apply Moderation Locks, or reset inappropriate profile content.

    Room bans

    Search a room and manage only its player-ban list.

    Loading rooms
    Select a room to manage its bans.
    `; 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 `
    `; } 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 ``; } 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)}

    `; } 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.

    '; 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 = `
    Your account

    Settings

    Manage how you appear on RecNet and in game.

    Profile

    Update your public identity and profile details.

    Profile images

    Use an image path from the server, such as PlayerImages/photo.png.

    Password

    Choose a new password with at least eight characters.

    Danger Zone

    Deleting your account is permanent. Your server account will be removed immediately.

    `; 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 => ``).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
    Current profile picture
    PNG, JPG, WebP, GIF, or BMP. Maximum 10 MB and 4096 x 4096.
    `; 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.

    '; document.querySelector('#ageVerificationLogin').onclick = () => loginDialog.showModal(); return; } app.innerHTML = `
    Age verification

    Submit your code

    Request a code in-game first (Settings ’ Age Verification). Enter it below, choose a method, and attach a photo. A staff member will review it manually - there's no automated ID or face matching.

    Verify

    `; 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.

    '; 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.

    `; 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)}

    `; 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();