Import december-2020-12-18 from private repo (scrubbed 2026-05-27)

Targets Rec Room 2020.12.18 client.

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
  Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
  on v1-december-* tag push.

Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
Alexa
2026-05-27 01:04:12 +03:00
commit 4edf25b2d5
375 changed files with 201291 additions and 0 deletions
+1056
View File
@@ -0,0 +1,1056 @@
// DorkNet admin SPA. Stays single-origin to api.rec.net so no CORS:
// login at /api/admin/v1/login, then every other call carries the
// returned JWT in Authorization: Bearer.
const API = '/api/admin/v1';
const TOKEN_KEY = 'dorknet.admin.token';
const ME_KEY = 'dorknet.admin.me';
const STORE_STOREFRONTS = [
'main', 'watch', 'all', 'rro', 'season:1',
'giftdrop:1', 'giftdrop:2', 'giftdrop:100', 'giftdrop:101',
'giftdrop:102', 'giftdrop:103', 'giftdrop:200', 'giftdrop:300',
'giftdrop:400', 'giftdrop:401', 'giftdrop:402', 'giftdrop:403',
'giftdrop:404', 'giftdrop:405', 'giftdrop:406', 'giftdrop:500',
'giftdrop:600', 'giftdrop:700',
];
const $ = (id) => document.getElementById(id);
const el = (tag, attrs, ...children) => {
const e = document.createElement(tag);
for (const k in attrs) {
if (k === 'class') e.className = attrs[k];
else if (k === 'on') for (const ev in attrs.on) e.addEventListener(ev, attrs.on[ev]);
else if (k === 'html') e.innerHTML = attrs[k];
else if (k.startsWith('data-')) e.setAttribute(k, attrs[k]);
else if (k === 'hidden' && attrs[k]) e.hidden = true;
else e[k] = attrs[k];
}
for (const c of children.flat()) if (c != null) e.append(c?.nodeType ? c : document.createTextNode(String(c)));
return e;
};
// ── HTTP ─────────────────────────────────────────────────────────────
function token() { return localStorage.getItem(TOKEN_KEY); }
async function api(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
const t = token();
if (t) headers.Authorization = `Bearer ${t}`;
if (opts.body && !(opts.body instanceof FormData) && typeof opts.body !== 'string') {
headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body);
}
const ctrl = new AbortController();
const timeoutMs = opts.timeout ?? 10000;
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
let res;
try {
res = await fetch(API + path, { ...opts, headers, signal: ctrl.signal });
} catch (err) {
if (err.name === 'AbortError') throw new Error(`request timed out after ${timeoutMs}ms (server unreachable?)`);
throw err;
} finally {
clearTimeout(timer);
}
if (res.status === 401) {
logout();
throw new Error('Unauthorized — please sign in again');
}
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try { const body = await res.json(); if (body?.error) msg += ` — ${body.error}`; if (body?.error_description) msg += ` (${body.error_description})`; } catch {}
throw new Error(msg);
}
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
return ct.includes('application/json') ? res.json() : res.text();
}
function toast(msg, kind = '') {
const t = $('toast');
t.textContent = msg;
t.className = 'toast ' + kind;
t.hidden = false;
clearTimeout(toast._h);
toast._h = setTimeout(() => { t.hidden = true; }, 3000);
}
// ── Login / logout ───────────────────────────────────────────────────
$('loginForm').addEventListener('submit', async (ev) => {
ev.preventDefault();
$('loginError').textContent = '';
try {
const res = await fetch(API + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: $('loginUsername').value,
password: $('loginPassword').value,
}),
});
if (!res.ok) {
let msg = 'Sign-in failed';
try { const body = await res.json(); if (body?.error === 'not_admin') msg = 'That account is not an admin.'; else if (body?.error === 'invalid_credentials') msg = 'Invalid username or password.'; } catch {}
throw new Error(msg);
}
const data = await res.json();
localStorage.setItem(TOKEN_KEY, data.access_token);
localStorage.setItem(ME_KEY, JSON.stringify({ id: data.account_id, username: data.username, displayName: data.display_name }));
showApp();
} catch (err) {
$('loginError').textContent = err.message;
}
});
$('logoutBtn').addEventListener('click', logout);
function logout() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ME_KEY);
$('app').hidden = true;
$('login').hidden = false;
}
function showApp() {
$('login').hidden = true;
$('app').hidden = false;
const me = JSON.parse(localStorage.getItem(ME_KEY) || '{}');
$('meName').textContent = me.displayName || me.username || `#${me.id}`;
activate('dashboard');
}
// ── Tabs ─────────────────────────────────────────────────────────────
document.querySelectorAll('nav .tab').forEach(btn => {
btn.addEventListener('click', () => activate(btn.dataset.tab));
});
function activate(name) {
document.querySelectorAll('nav .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(p => p.hidden = p.id !== `tab-${name}`);
if (name === 'dashboard') loadDashboard();
else if (name === 'players') loadPlayers();
else if (name === 'store') loadStore();
else if (name === 'reports') loadReports();
else if (name === 'ipbans') loadIpBans();
else if (name === 'audit') loadAudit();
else if (name === 'playerlogs') initPlayerLogs();
else if (name === 'importroom') { loadImportOwners(); loadMirrorRooms(); }
}
// ── Dashboard ────────────────────────────────────────────────────────
async function loadDashboard() {
try {
const s = await api('/stats');
$('statsGrid').replaceChildren(
stat('Players total', s.Players.Total),
stat('Online now', s.Players.OnlineNow, 'good'),
stat('Banned', s.Players.BannedNow, s.Players.BannedNow > 0 ? 'warn' : ''),
stat('Rooms', s.Rooms.Total),
stat('Inventions', s.Inventions),
stat('Open reports', s.Moderation.OpenReports, s.Moderation.OpenReports > 0 ? 'warn' : ''),
stat('Active IP bans', s.Moderation.ActiveIpBans),
);
renderTable($('topRoomsTable'),
['ID', 'Name', 'Visits', 'Visitors', 'Cheers'],
s.Rooms.TopByVisits,
r => [r.Id, r.Name,
(r.VisitCount ?? 0).toLocaleString(),
(r.VisitorCount ?? 0).toLocaleString(),
(r.CheerCount ?? 0).toLocaleString()]);
renderTable($('recentJoinsTable'),
['ID', 'Username', 'Joined'],
s.RecentJoins, r => [r.Id, r.Username, fmtDate(r.CreatedAt)]);
} catch (err) { toast(err.message, 'error'); }
}
function stat(label, value, kind = '') {
return el('div', { class: 'stat' },
el('div', { class: 'label' }, label),
el('div', { class: 'value ' + kind }, value));
}
// ── Players ──────────────────────────────────────────────────────────
let playerSearchTimer;
$('playerSearch').addEventListener('input', () => {
clearTimeout(playerSearchTimer);
playerSearchTimer = setTimeout(loadPlayers, 200);
});
$('refreshPlayers').addEventListener('click', loadPlayers);
async function loadPlayers() {
try {
const q = $('playerSearch').value.trim();
const path = q ? `/players?query=${encodeURIComponent(q)}` : '/players';
const rows = await api(path);
renderTable($('playersTable'),
['ID', 'Username', 'Display', 'Lvl', 'Flags', 'Status', 'Last seen', 'Actions'],
rows, p => [
p.Id,
p.Username,
p.DisplayName,
`${p.Level} (${p.XP} xp)`,
flagsBadges(p),
statusBadge(p),
fmtDate(p.LastSeenAt),
rowActions(p),
]);
} catch (err) { toast(err.message, 'error'); }
}
function flagsBadges(p) {
const badges = [];
if (p.IsAdmin) badges.push(el('span', { class: 'badge admin' }, 'admin'));
if (p.IsDeveloper) badges.push(el('span', { class: 'badge admin' }, 'dev'));
if (p.IsVerified) badges.push(el('span', { class: 'badge admin' }, 'verified'));
if (p.IsJunior) badges.push(el('span', { class: 'badge offline' }, 'jr'));
return el('span', {}, ...badges);
}
function statusBadge(p) {
if (p.BannedUntil && new Date(p.BannedUntil) > new Date())
return el('span', { class: 'badge banned' }, 'banned');
return el('span', { class: 'badge ' + (p.Online ? 'online' : 'offline') }, p.Online ? 'online' : 'offline');
}
function rowActions(p) {
return el('div', { class: 'actions' },
el('button', { on: { click: () => openPlayer(p.Id) } }, 'edit'),
el('button', {
class: p.Online ? '' : 'danger',
on: { click: () => kickPlayer(p) },
}, 'kick'),
);
}
async function kickPlayer(p) {
const reason = prompt(`Kick ${p.Username}? Optional reason:`, '');
if (reason === null) return;
try {
await api(`/players/${p.Id}/kick`, { method: 'POST', body: { Reason: reason } });
toast(`Kicked ${p.Username}`, 'good');
} catch (err) { toast(err.message, 'error'); }
}
// ── Player modal: edit / grant outfit / currency ─────────────────────
async function openPlayer(id) {
try {
const p = await api(`/players/${id}`);
$('modalTitle').textContent = `${p.DisplayName} (#${p.Id})`;
const body = $('modalBody');
body.replaceChildren(
playerSummarySection(p),
avatarSection(p),
currencySection(p),
grantsSection(p),
flagsSection(p),
bansSection(p),
);
$('playerModal').hidden = false;
} catch (err) { toast(err.message, 'error'); }
}
document.querySelectorAll('[data-close]').forEach(b => b.addEventListener('click', () => $('playerModal').hidden = true));
$('playerModal').addEventListener('click', (ev) => { if (ev.target.id === 'playerModal') $('playerModal').hidden = true; });
function playerSummarySection(p) {
return el('div', { class: 'section' },
el('h3', {}, 'Profile'),
el('div', { class: 'kvp' },
el('span', { class: 'k' }, 'Username'), el('span', {}, p.Username),
el('span', { class: 'k' }, 'Email'), el('span', {}, p.Email || '—'),
el('span', { class: 'k' }, 'Level / XP'), el('span', {}, `${p.Level} / ${p.XP}`),
el('span', { class: 'k' }, 'Created'), el('span', {}, fmtDate(p.CreatedAt)),
el('span', { class: 'k' }, 'Last seen'), el('span', {}, fmtDate(p.LastSeenAt)),
el('span', { class: 'k' }, 'Last IP'), el('span', {}, p.LastIp || '—'),
el('span', { class: 'k' }, 'Online'), statusBadge(p),
));
}
function avatarSection(p) {
const a = p.Avatar || {};
const outfit = el('input', { value: a.OutfitSelections || '', placeholder: '<head-guid>,<torso>,<legs>,<feet>,<accessory>' });
outfit.classList.add('mono');
const hair = el('input', { value: a.HairColor || '', placeholder: '81_c6R0my0qK9hYM_0a7LQ' });
hair.classList.add('mono');
const skin = el('input', { value: a.SkinColor || '', placeholder: 'cl2EzJ4v6kW3g4Oo9ZQ3hA' });
skin.classList.add('mono');
const face = el('input', { value: a.FaceFeatures || '', placeholder: '{"eyeId":0,…}' });
face.classList.add('mono');
const hairSwatch = el('div', { class: 'swatch' });
const skinSwatch = el('div', { class: 'swatch' });
const updateSwatch = (sw, val) => { sw.style.background = parseColor(val) || ''; };
updateSwatch(hairSwatch, hair.value);
updateSwatch(skinSwatch, skin.value);
hair.addEventListener('input', () => updateSwatch(hairSwatch, hair.value));
skin.addEventListener('input', () => updateSwatch(skinSwatch, skin.value));
return el('div', { class: 'section' },
el('h3', {}, 'Avatar / outfit'),
el('label', {}, 'Outfit selections (5 slot GUIDs, comma-separated)', outfit),
el('label', {}, 'Hair color', el('div', { class: 'swatch-input' }, hair, hairSwatch)),
el('label', {}, 'Skin color', el('div', { class: 'swatch-input' }, skin, skinSwatch)),
el('label', {}, 'Face features (opaque JSON)', face),
el('button', {
class: 'primary',
on: { click: async () => {
try {
await api(`/players/${p.Id}/avatar`, {
method: 'POST',
body: {
OutfitSelections: outfit.value,
HairColor: hair.value,
SkinColor: skin.value,
FaceFeatures: face.value,
},
});
toast('Avatar saved', 'good');
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Save avatar'),
);
}
// Convert "r,g,b,a" (0-1 floats) into a CSS background. Accepts "r,g,b"
// too. Returns null when unparseable so the swatch falls back to its
// checker-pattern default.
function parseColor(s) {
if (!s) return null;
const parts = s.split(',').map(t => parseFloat(t.trim()));
if (parts.length < 3 || parts.some(isNaN)) return null;
const [r, g, b, a = 1] = parts;
const c = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255);
return `rgba(${c(r)}, ${c(g)}, ${c(b)}, ${a})`;
}
function currencySection(p) {
const balances = (p.Balances || []).map(b =>
el('div', { class: 'kvp' },
el('span', { class: 'k' }, `Currency #${b.CurrencyType}`),
el('span', {}, b.Balance.toLocaleString())));
const type = el('input', { type: 'number', value: 2, placeholder: 'currency type' });
const amount = el('input', { type: 'number', value: 100, placeholder: 'amount (negative deducts)' });
const reason = el('input', { value: 'admin_grant', placeholder: 'reason' });
return el('div', { class: 'section' },
el('h3', {}, 'Currency'),
...(balances.length ? balances : [el('p', { class: 'muted' }, 'No balances yet.')]),
el('div', { class: 'row' }, type, amount, reason,
el('button', {
class: 'primary',
on: { click: async () => {
try {
const r = await api(`/players/${p.Id}/currency`, { method: 'POST', body: {
CurrencyType: parseInt(type.value, 10),
Amount: parseInt(amount.value, 10),
Reason: reason.value,
}});
toast(`New balance: ${r.balance}`, 'good');
openPlayer(p.Id);
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Adjust currency'),
),
);
}
function grantsSection(p) {
const itemId = el('input', { value: '', placeholder: 'item GUID' });
const qty = el('input', { type: 'number', value: 1, min: 1, placeholder: 'qty' });
const xpAmount = el('input', { type: 'number', value: 100, placeholder: 'xp amount' });
const xpReason = el('input', { value: 'admin_grant', placeholder: 'reason' });
return el('div', { class: 'section' },
el('h3', {}, 'Grants'),
el('div', { class: 'row' }, itemId, qty,
el('button', {
class: 'primary',
on: { click: async () => {
if (!itemId.value.trim()) return toast('Item GUID required', 'error');
try {
await api(`/players/${p.Id}/inventory/grant`, { method: 'POST', body: {
ItemId: itemId.value.trim(), Quantity: parseInt(qty.value, 10),
}});
toast(`Granted ${itemId.value} x${qty.value}`, 'good');
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Grant item'),
),
el('div', { class: 'row' }, xpAmount, xpReason,
el('button', {
on: { click: async () => {
try {
const r = await api(`/players/${p.Id}/xp`, { method: 'POST', body: {
Amount: parseInt(xpAmount.value, 10), Reason: xpReason.value,
}});
toast(`Now level ${r.level} (${r.xp} xp)`, 'good');
openPlayer(p.Id);
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Grant XP'),
),
);
}
function flagsSection(p) {
const verified = el('input', { type: 'checkbox', checked: p.IsVerified });
const dev = el('input', { type: 'checkbox', checked: p.IsDeveloper });
const junior = el('input', { type: 'checkbox', checked: p.IsJunior });
const display = el('input', { value: p.DisplayName });
const checkbox = (lbl, input) => el('label', { class: 'checkbox-label' }, input, lbl);
return el('div', { class: 'section' },
el('h3', {}, 'Flags / display name'),
el('div', { class: 'row' }, checkbox('Verified', verified), checkbox('Developer', dev), checkbox('Junior', junior),
el('button', {
on: { click: async () => {
try {
await api(`/players/${p.Id}/flags`, { method: 'POST', body: {
IsVerified: verified.checked, IsDeveloper: dev.checked, IsJunior: junior.checked,
}});
toast('Flags updated', 'good');
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Save flags'),
),
el('div', { class: 'row' }, display,
el('button', {
on: { click: async () => {
try {
await api(`/players/${p.Id}/displayName`, { method: 'POST', body: { DisplayName: display.value }});
toast('Display name updated', 'good');
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Set display name'),
),
);
}
function bansSection(p) {
const days = el('input', { type: 'number', value: 7, min: 1, max: 3650 });
const reason = el('input', { value: '', placeholder: 'reason' });
const banned = p.BannedUntil && new Date(p.BannedUntil) > new Date();
const banInfo = banned
? el('p', { class: 'muted' }, `Banned until ${fmtDate(p.BannedUntil)}`)
: el('p', { class: 'muted' }, 'Not banned.');
return el('div', { class: 'section' },
el('h3', {}, 'Moderation'),
banInfo,
el('div', { class: 'row' }, days, reason,
el('button', {
class: 'danger',
on: { click: async () => {
try {
await api(`/players/${p.Id}/ban`, { method: 'POST', body: {
DurationDays: parseInt(days.value, 10), Reason: reason.value,
}});
toast(`Banned ${p.Username}`, 'good');
openPlayer(p.Id);
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Ban'),
el('button', {
on: { click: async () => {
try {
await api(`/players/${p.Id}/unban`, { method: 'POST', body: { Reason: reason.value }});
toast('Unbanned', 'good');
openPlayer(p.Id);
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Unban'),
el('button', {
on: { click: async () => {
if (!confirm(`Promote ${p.Username} to admin?`)) return;
try {
await api(`/players/${p.Id}/promote`, { method: 'POST' });
toast('Promoted to admin', 'good');
openPlayer(p.Id);
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Promote admin'),
el('button', {
on: { click: async () => {
if (!confirm(`Demote ${p.Username} from admin?`)) return;
try {
await api(`/players/${p.Id}/demote`, { method: 'POST' });
toast('Demoted', 'good');
openPlayer(p.Id);
} catch (err) { toast(err.message, 'error'); }
}},
}, 'Demote'),
),
);
}
// ── Reports ──────────────────────────────────────────────────────────
$('refreshReports').addEventListener('click', loadReports);
async function loadReports() {
try {
const rows = await api('/reports');
renderTable($('reportsTable'),
['ID', 'Reporter', 'Target', 'Category', 'Message', 'When', 'Actions'],
rows, r => [
r.Id, r.ReporterPlayerId, r.TargetPlayerId, r.ReportCategory,
truncate(r.Message, 60), fmtDate(r.CreatedAt),
el('div', { class: 'actions' },
el('button', { on: { click: () => resolveReport(r) }}, 'resolve'),
el('button', { on: { click: () => openPlayer(r.TargetPlayerId) }}, 'view target'),
),
]);
} catch (err) { toast(err.message, 'error'); }
}
async function resolveReport(r) {
const note = prompt(`Resolve report #${r.Id}? Note:`, '');
if (note === null) return;
try {
await api(`/reports/${r.Id}/resolve`, { method: 'POST', body: { Note: note }});
toast('Resolved', 'good');
loadReports();
} catch (err) { toast(err.message, 'error'); }
}
// ── IP bans ──────────────────────────────────────────────────────────
$('ipBanForm').addEventListener('submit', async (ev) => {
ev.preventDefault();
const cidr = $('cidrInput').value.trim();
const reason = $('cidrReason').value.trim();
const days = $('cidrDays').value;
try {
await api('/ipbans', { method: 'POST', body: {
Cidr: cidr, Reason: reason, DurationDays: days ? parseInt(days, 10) : null,
}});
toast('IP ban added', 'good');
$('cidrInput').value = '';
$('cidrReason').value = '';
$('cidrDays').value = '';
loadIpBans();
} catch (err) { toast(err.message, 'error'); }
});
async function loadIpBans() {
try {
const rows = await api('/ipbans');
renderTable($('ipBansTable'),
['ID', 'CIDR', 'Reason', 'Banned at', 'Until', 'Actions'],
rows, r => [
r.Id, r.Cidr, r.Reason || '—', fmtDate(r.BannedAt),
r.Until ? fmtDate(r.Until) : 'forever',
el('div', { class: 'actions' },
el('button', { class: 'danger', on: { click: () => removeIpBan(r) }}, 'remove')),
]);
} catch (err) { toast(err.message, 'error'); }
}
async function removeIpBan(r) {
if (!confirm(`Remove ban on ${r.Cidr}?`)) return;
try {
await api(`/ipbans/${r.Id}`, { method: 'DELETE' });
toast('Removed', 'good');
loadIpBans();
} catch (err) { toast(err.message, 'error'); }
}
// ── Audit ────────────────────────────────────────────────────────────
$('refreshAudit').addEventListener('click', loadAudit);
async function loadAudit() {
try {
const rows = await api('/audit');
renderTable($('auditTable'),
['When', 'Admin', 'Action', 'Target', 'Reason'],
rows, r => [fmtDate(r.Timestamp), r.AdminPlayerId, r.Action, `${r.TargetType}:${r.TargetId}`, truncate(r.Reason, 80)]);
} catch (err) { toast(err.message, 'error'); }
}
// ── Broadcast ────────────────────────────────────────────────────────
$('broadcastForm').addEventListener('submit', async (ev) => {
ev.preventDefault();
const msg = $('broadcastMessage').value.trim();
if (!msg) return;
try {
await api('/broadcast', { method: 'POST', body: { Message: msg }});
toast('Broadcast sent', 'good');
$('broadcastMessage').value = '';
} catch (err) { toast(err.message, 'error'); }
});
// ── Import room ─────────────────────────────────────────────────────
//
// Pairs every uploaded .room file with a scene-folder name. Folder picker
// (webkitdirectory) auto-fills folders via File.webkitRelativePath. The
// individual-file picker fallback derives the scene name from the file
// stem (everything before .room) so single-file uploads still work.
const importState = {
// Each entry: { file, sceneName }
entries: [],
// Cached player list for the owner dropdown — fetched once when the
// tab opens, re-filtered client-side as the user types.
players: null,
};
async function loadImportOwners(force = false) {
// Re-fetch on every tab switch unless we have a populated cache —
// that way a transient network blip is recovered the next time the
// user opens the tab. (Old code cached an empty [] on failure and
// never retried.)
if (!force && Array.isArray(importState.players) && importState.players.length > 0) {
renderOwnerDropdown('');
return;
}
importState.ownersError = null;
importState.players = null;
renderOwnerDropdown(''); // shows "loading…"
try {
// 200 is the server-side max page size for /admin/v1/players.
const list = await api('/players?take=200');
importState.players = Array.isArray(list) ? list : [];
} catch (err) {
importState.ownersError = err.message || String(err);
importState.players = [];
toast(`Couldn't load player list: ${importState.ownersError}`, 'error');
}
renderOwnerDropdown('');
}
function renderOwnerDropdown(filter) {
const sel = $('importOwner');
const f = (filter || '').trim().toLowerCase();
const players = importState.players;
const matched = (players || []).filter(p =>
!f ||
(p.Username || '').toLowerCase().includes(f) ||
(p.DisplayName || '').toLowerCase().includes(f) ||
String(p.Id).includes(f)
);
// Preserve current selection if still in the filtered list.
const current = sel.value;
sel.innerHTML = '';
let placeholder;
if (importState.ownersError) placeholder = `— failed: ${importState.ownersError} —`;
else if (players === null) placeholder = '— loading accounts… —';
else if (players.length === 0) placeholder = '— no accounts in DB —';
else if (matched.length === 0) placeholder = '— no matches —';
else placeholder = `— pick an account (${matched.length}) —`;
sel.append(el('option', { value: '' }, placeholder));
for (const p of matched) {
const label = `${p.DisplayName || p.Username} (${p.Username}, id=${p.Id})${p.IsAdmin ? ' [admin]' : ''}`;
sel.append(el('option', { value: String(p.Id) }, label));
}
if (matched.some(p => String(p.Id) === current)) sel.value = current;
}
function refreshImportPreview() {
const summary = $('importFileSummary');
const table = $('importPreviewTable');
const tbody = table.querySelector('tbody');
tbody.innerHTML = '';
if (importState.entries.length === 0) {
summary.textContent = 'No files selected.';
table.hidden = true;
$('importSubmit').disabled = true;
return;
}
for (const e of importState.entries) {
const tr = document.createElement('tr');
tr.append(
el('td', { class: 'mono' }, e.sceneName),
el('td', {}, e.file.name),
el('td', { style: 'text-align:right' }, e.file.size.toLocaleString()),
);
tbody.append(tr);
}
summary.textContent = `${importState.entries.length} scene(s) staged, ${importState.entries.reduce((a, e) => a + e.file.size, 0).toLocaleString()} bytes total.`;
table.hidden = false;
$('importSubmit').disabled = false;
}
function deriveSceneName(file) {
// webkitRelativePath is "FolderRoot/SceneName/blob.room" when picked
// via the folder picker. Use the second-to-last segment.
const rel = file.webkitRelativePath || '';
if (rel) {
const parts = rel.split('/').filter(Boolean);
if (parts.length >= 2) return parts[parts.length - 2];
}
// Fallback for individual-file picker: strip ".room" from name.
return file.name.replace(/\.room$/i, '');
}
function setImportFiles(fileList) {
importState.entries = Array.from(fileList)
.filter(f => f.name.toLowerCase().endsWith('.room'))
.map(f => ({ file: f, sceneName: deriveSceneName(f) }));
refreshImportPreview();
}
$('importFolder').addEventListener('change', (ev) => setImportFiles(ev.target.files));
$('importFiles').addEventListener('change', (ev) => setImportFiles(ev.target.files));
$('importOwnerFilter').addEventListener('input', (ev) => renderOwnerDropdown(ev.target.value));
async function loadMirrorRooms() {
const sel = $('mirrorHtrRoomSelect');
sel.innerHTML = '<option value="">— loading rooms… —</option>';
try {
const rooms = await api('/rooms');
sel.innerHTML = '';
sel.append(el('option', { value: '' }, `— pick a room (${rooms.length}) —`));
for (const r of rooms) {
const tag = r.IsAGRoom ? 'AG' : (r.IsDormRoom ? 'dorm' : 'orig');
sel.append(el('option', { value: String(r.Id) },
`[${tag}] ${r.Name} — id ${r.Id}, ${r.BlobCount} blob${r.BlobCount === 1 ? '' : 's'}`));
}
} catch (err) {
sel.innerHTML = `<option value="">— failed: ${err.message} —</option>`;
}
}
$('mirrorHtrBtn').addEventListener('click', async () => {
const roomId = parseInt($('mirrorHtrRoomSelect').value, 10);
if (!roomId) { toast('Pick a room first', 'error'); return; }
const status = $('mirrorHtrStatus');
const btn = $('mirrorHtrBtn');
btn.disabled = true;
status.textContent = `Mirroring room ${roomId}…`;
try {
const r = await api(`/rooms/${roomId}/mirror-htr`, { method: 'POST' });
status.textContent =
`room=${r.roomId} scannedBlobs=${r.scannedBlobs} uniqueRefs=${r.uniqueRefs}\n` +
`alreadyMirrored=${r.alreadyMirrored} downloaded=${r.downloaded} roomParseFailures=${r.roomParseFailures}\n` +
(r.assetDownloadFailures && r.assetDownloadFailures.length
? `failures:\n ${r.assetDownloadFailures.join('\n ')}`
: 'no download failures.');
toast(`Mirrored: refs=${r.uniqueRefs}, new=${r.downloaded}, skipped=${r.alreadyMirrored}`, 'good');
} catch (err) {
toast(err.message, 'error');
status.textContent = `Failed: ${err.message}`;
} finally {
btn.disabled = false;
}
});
$('importRoomForm').addEventListener('submit', async (ev) => {
ev.preventDefault();
if (importState.entries.length === 0) {
toast('Pick a folder or .room files first', 'error');
return;
}
const name = $('importName').value.trim();
if (!name) { toast('Room name required', 'error'); return; }
const ownerId = $('importOwner').value;
if (!ownerId) { toast('Pick an owner account', 'error'); return; }
const status = $('importStatus');
const submitBtn = $('importSubmit');
submitBtn.disabled = true;
status.textContent = `Uploading ${importState.entries.length} scene(s)…`;
try {
const fd = new FormData();
fd.append('name', name);
fd.append('creatorPlayerId', ownerId);
const desc = $('importDesc').value.trim();
if (desc) fd.append('description', desc);
const entry = $('importEntry').value.trim();
if (entry) fd.append('entryScene', entry);
for (const e of importState.entries) {
fd.append('files', e.file, e.file.name);
fd.append('scenePaths', e.sceneName);
}
const result = await api('/rooms/import', { method: 'POST', body: fd });
toast(`Imported "${result.roomName}" (id ${result.roomId}, ${result.sceneCount} scenes)`, 'good');
const failNote = result.normalizationFailures && result.normalizationFailures.length
? ` <span class="error">⚠ ${result.normalizationFailures.length} blob(s) failed normalization (uploaded as-is): ${result.normalizationFailures.map(f => f.scene).join(', ')}</span>`
: '';
const htrNote = result.htrMirrorStarted
? ' <span class="muted">.htr asset mirror running in background — watch the server log for [htr-mirror] lines.</span>'
: '';
status.innerHTML = `<strong>Done.</strong> Room id <code>${result.roomId}</code>, entry <code>${result.entryScene}</code>, ` +
`${result.sceneCount} scenes wired up (${result.normalizedSceneCount}/${result.sceneCount} round-tripped through 2020 schema).${failNote}${htrNote} ` +
`Try <code>/goto room/${result.roomName}</code> in-game.`;
importState.entries = [];
$('importName').value = '';
$('importDesc').value = '';
$('importEntry').value = '';
$('importFolder').value = '';
$('importFiles').value = '';
refreshImportPreview();
} catch (err) {
toast(err.message, 'error');
status.textContent = `Failed: ${err.message}`;
submitBtn.disabled = false;
}
});
// ── Helpers ──────────────────────────────────────────────────────────
function renderTable(tableEl, headers, rows, mapRow) {
const thead = el('thead', {}, el('tr', {}, ...headers.map(h => el('th', {}, h))));
const tbody = el('tbody', {});
if (!rows || rows.length === 0) {
tbody.append(el('tr', {}, el('td', { colSpan: headers.length, class: 'empty' }, 'Nothing here yet.')));
} else {
for (const r of rows) {
const cells = mapRow(r);
tbody.append(el('tr', {}, ...cells.map((c, i) => {
const td = el('td', {}, c);
// Right-align action cells; mono-format ID columns.
if (typeof c === 'object' && c?.classList?.contains?.('actions')) td.style.textAlign = 'right';
if (i === 0) td.classList.add('mono');
return td;
})));
}
}
tableEl.replaceChildren(thead, tbody);
}
function fmtDate(s) {
if (!s) return '—';
const d = new Date(s);
if (isNaN(d)) return '—';
return d.toLocaleString();
}
function truncate(s, n) {
if (!s) return '';
return s.length > n ? s.slice(0, n) + '…' : s;
}
// ── Store catalog ────────────────────────────────────────────────────
$('refreshStore').addEventListener('click', loadStore);
$('storeFilterStorefront').addEventListener('change', loadStore);
$('storeFilterCategory').addEventListener('change', loadStore);
$('newStoreItemBtn').addEventListener('click', () => openStoreItem(null));
document.querySelectorAll('[data-close-store]').forEach(b =>
b.addEventListener('click', () => $('storeModal').hidden = true));
$('storeModal').addEventListener('click', (ev) => {
if (ev.target.id === 'storeModal') $('storeModal').hidden = true;
});
async function loadStore() {
try {
const sf = $('storeFilterStorefront').value;
const cat = $('storeFilterCategory').value;
const params = new URLSearchParams();
if (sf) params.set('storefront', sf);
if (cat) params.set('category', cat);
const path = '/storeitems' + (params.toString() ? '?' + params : '');
const rows = await api(path);
renderTable($('storeTable'),
['ID', 'Slug', 'Name', 'Cat', 'Storefront', 'Price', 'Active', 'Actions'],
rows, r => [
r.Id,
r.Slug,
r.DisplayName,
r.Category,
r.Storefront,
`${r.Price.toLocaleString()} (${r.CurrencyType})`,
r.IsActive
? el('span', { class: 'badge online' }, 'active')
: el('span', { class: 'badge offline' }, 'inactive'),
el('div', { class: 'actions' },
el('button', { on: { click: () => openStoreItem(r) }}, 'edit'),
el('button', { class: 'danger', on: { click: () => deleteStoreItem(r) }}, 'delete'),
),
]);
} catch (err) { toast(err.message, 'error'); }
}
function openStoreItem(item) {
const isNew = !item;
$('storeModalTitle').textContent = isNew
? 'New store item'
: `Edit: ${item.DisplayName} (#${item.Id})`;
const slug = el('input', { value: item?.Slug || '', placeholder: 'unique slug, e.g. my-cool-hat' });
if (!isNew) slug.disabled = true;
const displayName = el('input', { value: item?.DisplayName || '', placeholder: 'shown in store' });
const description = el('input', { value: item?.Description || '', placeholder: 'item description' });
const category = el('select', {});
for (const c of ['head','torso','legs','feet','accessory','hair','face','consumable','roomtemplate','emote']) {
category.append(el('option', { value: c, selected: (item?.Category || 'accessory') === c }, c));
}
const storefront = el('select', {});
const currentStorefront = item?.Storefront || 'main';
if (!STORE_STOREFRONTS.includes(currentStorefront)) {
storefront.append(el('option', { value: currentStorefront, selected: true }, currentStorefront));
}
for (const s of STORE_STOREFRONTS) {
storefront.append(el('option', { value: s, selected: currentStorefront === s }, s));
}
const imageName = el('input', { value: item?.ImageName || '', placeholder: 'cdn filename' });
const currencyType = el('input', { type: 'number', value: item?.CurrencyType ?? 2 });
const price = el('input', { type: 'number', value: item?.Price ?? 100, min: 0 });
const isActive = el('input', { type: 'checkbox', checked: item?.IsActive ?? true });
const isLimitedTime = el('input', { type: 'checkbox', checked: item?.IsLimitedTime ?? false });
const availableUntil = el('input', { type: 'datetime-local',
value: item?.AvailableUntil ? new Date(item.AvailableUntil).toISOString().slice(0,16) : '' });
const checkbox = (lbl, input) => el('label', { class: 'checkbox-label' }, input, lbl);
$('storeModalBody').replaceChildren(
el('label', {}, 'Slug', slug),
el('label', {}, 'Display name', displayName),
el('label', {}, 'Description', description),
el('label', {}, 'Category', category),
el('label', {}, 'Storefront', storefront),
el('label', {}, 'Image filename (cdn.rec.net/<this>)', imageName),
el('div', { class: 'row' },
el('label', { style: 'flex: 1;' }, 'Currency type', currencyType),
el('label', { style: 'flex: 1;' }, 'Price', price)),
el('label', {}, 'Available until (optional)', availableUntil),
el('div', { class: 'row' }, checkbox('Active', isActive), checkbox('Limited-time', isLimitedTime)),
el('button', {
class: 'primary',
on: { click: async () => {
const body = {
Slug: slug.value.trim(),
DisplayName: displayName.value.trim(),
Description: description.value,
Category: category.value,
Storefront: storefront.value.trim(),
ImageName: imageName.value.trim(),
CurrencyType: parseInt(currencyType.value, 10),
Price: parseInt(price.value, 10),
IsActive: isActive.checked,
IsLimitedTime: isLimitedTime.checked,
AvailableUntil: availableUntil.value ? new Date(availableUntil.value).toISOString() : null,
};
try {
if (isNew) {
await api('/storeitems', { method: 'POST', body });
toast('Item created', 'good');
} else {
await api(`/storeitems/${item.Id}`, { method: 'POST', body });
toast('Item updated', 'good');
}
$('storeModal').hidden = true;
loadStore();
} catch (err) { toast(err.message, 'error'); }
}},
}, isNew ? 'Create' : 'Save'),
);
$('storeModal').hidden = false;
}
async function deleteStoreItem(r) {
if (!confirm(`Delete ${r.Slug}? This is permanent — for soft-disable, edit and uncheck Active.`)) return;
try {
await api(`/storeitems/${r.Id}`, { method: 'DELETE' });
toast('Deleted', 'good');
loadStore();
} catch (err) { toast(err.message, 'error'); }
}
// ── Player logs ─────────────────────────────────────────────────────
//
// Per-player request log (Redis-backed ring buffer on the server).
// Reuses the existing /admin/v1/players list to populate the dropdown
// so the operator doesn't need to know account ids by heart, then hits
// /admin/v1/players/{id}/logs?take=N to pull the most-recent entries.
const playerLogsState = {
players: null,
selectedId: null,
autoTimer: null,
};
let playerLogsInited = false;
async function initPlayerLogs() {
if (!playerLogsInited) {
playerLogsInited = true;
$('playerLogsSelect').addEventListener('change', () => {
playerLogsState.selectedId = parseInt($('playerLogsSelect').value, 10) || null;
if (playerLogsState.selectedId) loadPlayerLogs();
else $('playerLogsTable').innerHTML = '';
});
$('refreshPlayerLogs').addEventListener('click', loadPlayerLogs);
$('playerLogsFilter').addEventListener('input', renderPlayerLogsTable);
$('playerLogsAuto').addEventListener('change', () => {
if ($('playerLogsAuto').checked) {
playerLogsState.autoTimer = setInterval(() => {
if (playerLogsState.selectedId) loadPlayerLogs(true);
}, 5000);
} else {
clearInterval(playerLogsState.autoTimer);
playerLogsState.autoTimer = null;
}
});
}
// Reuse the player list — same endpoint the Import Room dropdown uses.
try {
const list = await api('/players?take=200');
playerLogsState.players = Array.isArray(list) ? list : [];
} catch (err) {
playerLogsState.players = [];
toast(`Couldn't load player list: ${err.message}`, 'error');
}
const sel = $('playerLogsSelect');
const current = sel.value;
sel.innerHTML = '';
sel.append(el('option', { value: '' },
playerLogsState.players.length === 0
? '— no accounts in DB —'
: `— pick an account (${playerLogsState.players.length}) —`));
for (const p of playerLogsState.players) {
sel.append(el('option', { value: String(p.Id) }, `${p.Username} (#${p.Id})`));
}
if (current && playerLogsState.players.some(p => String(p.Id) === current)) sel.value = current;
}
let playerLogsRecent = [];
async function loadPlayerLogs(silent = false) {
if (!playerLogsState.selectedId) return;
const take = Math.max(10, Math.min(500, parseInt($('playerLogsTake').value, 10) || 200));
try {
playerLogsRecent = await api(`/players/${playerLogsState.selectedId}/logs?take=${take}`);
if (!Array.isArray(playerLogsRecent)) playerLogsRecent = [];
renderPlayerLogsTable();
} catch (err) {
if (!silent) toast(err.message, 'error');
}
}
function renderPlayerLogsTable() {
const filter = ($('playerLogsFilter').value || '').trim().toLowerCase();
const rows = filter
? playerLogsRecent.filter(e =>
(e.path || '').toLowerCase().includes(filter) ||
(e.method || '').toLowerCase().includes(filter) ||
String(e.status).includes(filter) ||
(e.host || '').toLowerCase().includes(filter))
: playerLogsRecent;
$('playerLogsSummary').textContent = filter
? `${rows.length} of ${playerLogsRecent.length} entries match "${filter}"`
: `${rows.length} entries`;
renderTable($('playerLogsTable'),
['When', 'Method', 'Host', 'Path', 'Status', 'Took', 'Resp'],
rows,
e => {
const statusKind = e.status >= 500 ? 'bad' : e.status >= 400 ? 'warn' : '';
return [
fmtDate(e.ts),
e.method,
e.host,
truncate((e.path || '') + (e.query || ''), 80),
el('span', { class: statusKind }, String(e.status)),
`${e.elapsedMs}ms`,
truncate(e.respBody || '', 100),
];
});
}
// ── Boot ─────────────────────────────────────────────────────────────
if (token()) showApp();
@@ -0,0 +1,249 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DorkNet Admin</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="login" class="card">
<h1>DorkNet Admin</h1>
<p class="muted">Sign in with an admin account.</p>
<form id="loginForm">
<label>Username<input id="loginUsername" autocomplete="username" required></label>
<label>Password<input id="loginPassword" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
<div id="loginError" class="error"></div>
</form>
</div>
<div id="app" hidden>
<header>
<h1>DorkNet Admin</h1>
<div class="who">
Signed in as <span id="meName"></span>
<button id="logoutBtn" class="link">log out</button>
</div>
</header>
<nav>
<button data-tab="dashboard" class="tab active">Dashboard</button>
<button data-tab="players" class="tab">Players</button>
<button data-tab="store" class="tab">Store</button>
<button data-tab="reports" class="tab">Reports</button>
<button data-tab="ipbans" class="tab">IP Bans</button>
<button data-tab="audit" class="tab">Audit</button>
<button data-tab="playerlogs" class="tab">Player Logs</button>
<button data-tab="broadcast" class="tab">Broadcast</button>
<button data-tab="importroom" class="tab">Import Room</button>
</nav>
<section id="tab-dashboard" class="tab-panel">
<h2>Server snapshot</h2>
<div id="statsGrid" class="grid"></div>
<h3>Top rooms by visits</h3>
<table id="topRoomsTable" class="table"></table>
<h3>Recent joins</h3>
<table id="recentJoinsTable" class="table"></table>
</section>
<section id="tab-players" class="tab-panel" hidden>
<h2>Players</h2>
<div class="row">
<input id="playerSearch" placeholder="search by name…">
<button id="refreshPlayers">Refresh</button>
</div>
<table id="playersTable" class="table"></table>
</section>
<section id="tab-store" class="tab-panel" hidden>
<h2>Store catalog</h2>
<div class="row">
<select id="storeFilterStorefront">
<option value="">All storefronts</option>
<option value="main">main</option>
<option value="watch">watch</option>
<option value="all">all</option>
<option value="rro">rro</option>
<option value="giftdrop:1">giftdrop:1</option>
<option value="giftdrop:2">giftdrop:2</option>
<option value="giftdrop:100">giftdrop:100</option>
<option value="giftdrop:101">giftdrop:101</option>
<option value="giftdrop:102">giftdrop:102</option>
<option value="giftdrop:103">giftdrop:103</option>
<option value="giftdrop:200">giftdrop:200</option>
<option value="giftdrop:300">giftdrop:300</option>
<option value="giftdrop:400">giftdrop:400</option>
<option value="giftdrop:401">giftdrop:401</option>
<option value="giftdrop:402">giftdrop:402</option>
<option value="giftdrop:403">giftdrop:403</option>
<option value="giftdrop:404">giftdrop:404</option>
<option value="giftdrop:405">giftdrop:405</option>
<option value="giftdrop:406">giftdrop:406</option>
<option value="giftdrop:500">giftdrop:500</option>
<option value="giftdrop:600">giftdrop:600</option>
<option value="giftdrop:700">giftdrop:700</option>
<option value="season:1">season:1</option>
</select>
<select id="storeFilterCategory">
<option value="">All categories</option>
<option value="head">head</option>
<option value="torso">torso</option>
<option value="legs">legs</option>
<option value="feet">feet</option>
<option value="accessory">accessory</option>
<option value="hair">hair</option>
<option value="face">face</option>
<option value="consumable">consumable</option>
<option value="roomtemplate">roomtemplate</option>
</select>
<button id="refreshStore">Refresh</button>
<button id="newStoreItemBtn" class="primary">+ New item</button>
</div>
<table id="storeTable" class="table"></table>
</section>
<section id="tab-reports" class="tab-panel" hidden>
<h2>Open reports</h2>
<button id="refreshReports">Refresh</button>
<table id="reportsTable" class="table"></table>
</section>
<section id="tab-ipbans" class="tab-panel" hidden>
<h2>IP bans</h2>
<form id="ipBanForm" class="row">
<input id="cidrInput" placeholder="1.2.3.4 or 10.0.0.0/24" required>
<input id="cidrReason" placeholder="reason">
<input id="cidrDays" type="number" min="1" max="3650" placeholder="days (blank = forever)">
<button type="submit">Add ban</button>
</form>
<table id="ipBansTable" class="table"></table>
</section>
<section id="tab-audit" class="tab-panel" hidden>
<h2>Audit log</h2>
<button id="refreshAudit">Refresh</button>
<table id="auditTable" class="table"></table>
</section>
<section id="tab-playerlogs" class="tab-panel" hidden>
<h2>Player request log</h2>
<p class="muted">
Last <strong>500</strong> HTTP calls made by a single player (newest first), pulled from
Redis. Useful for diagnosing what API call a player was making when something broke.
</p>
<form id="playerLogsForm" class="row">
<label style="flex:2">
Player
<select id="playerLogsSelect">
<option value="">— pick an account —</option>
</select>
</label>
<label style="flex:1">
Filter (case-insensitive)
<input id="playerLogsFilter" placeholder="path or status">
</label>
<label style="flex:0 0 90px">
Take
<input id="playerLogsTake" type="number" min="10" max="500" value="200">
</label>
<button type="button" id="refreshPlayerLogs">Refresh</button>
<label class="row" style="gap:.4rem;align-items:center;flex:0 0 auto;margin-top:1.4rem">
<input id="playerLogsAuto" type="checkbox"> auto-refresh 5 s
</label>
</form>
<p id="playerLogsSummary" class="muted" style="margin-top:.5rem"></p>
<table id="playerLogsTable" class="table"></table>
</section>
<section id="tab-broadcast" class="tab-panel" hidden>
<h2>Broadcast message</h2>
<p class="muted">Pushes a server-maintenance notification to every connected player.</p>
<form id="broadcastForm">
<textarea id="broadcastMessage" rows="4" placeholder="Server going down for maintenance in 5 minutes…" required></textarea>
<button type="submit">Send broadcast</button>
</form>
</section>
<section id="tab-importroom" class="tab-panel" hidden>
<h2>Import a room</h2>
<p class="muted">
Upload a folder containing per-scene <code>.room</code> blobs, or pick the .room files individually.
Each scene gets its own sub-room; the one named <strong>Lobby</strong> (or the entry-scene field below) becomes the
entry. All scenes default to the Maker Room (Basement) stage GUID — edit per row in SQL after import if a different
base stage is needed.
</p>
<form id="importRoomForm">
<div class="row">
<label style="flex:2">Room name<input id="importName" placeholder="YarrHarrHeist" required></label>
<label style="flex:1">Entry scene<input id="importEntry" placeholder="Lobby"></label>
</div>
<label>Description (optional)<input id="importDesc" placeholder="Imported from archive"></label>
<div class="row">
<label style="flex:2">
Owner
<select id="importOwner" required>
<option value="">— pick an account —</option>
</select>
</label>
<label style="flex:1">
Filter
<input id="importOwnerFilter" placeholder="search by name…">
</label>
</div>
<div class="row">
<label style="flex:1">
Pick a folder
<input id="importFolder" type="file" webkitdirectory directory multiple>
</label>
<label style="flex:1">
…or pick individual <code>.room</code> files
<input id="importFiles" type="file" multiple accept=".room">
</label>
</div>
<p id="importFileSummary" class="muted"></p>
<table id="importPreviewTable" class="table" hidden>
<thead><tr><th>Scene</th><th>File</th><th>Bytes</th></tr></thead>
<tbody></tbody>
</table>
<button type="submit" id="importSubmit" disabled>Import</button>
<div id="importStatus" class="muted" style="margin-top:.5rem"></div>
</form>
<h3 style="margin-top:2rem">Re-mirror .htr assets for an existing room</h3>
<p class="muted">
Forces a synchronous re-scan of an already-imported room's blobs and downloads any
Holotar / AudioSampler <code>.htr</code> refs that aren't yet in <code>RoomDataBlobs</code>.
Use to backfill rooms imported before the auto-mirror existed, or to debug whether the
C# extractor is finding refs.
</p>
<div class="row">
<select id="mirrorHtrRoomSelect" style="flex:2">
<option value="">— loading rooms… —</option>
</select>
<button type="button" id="mirrorHtrBtn">Mirror .htr</button>
</div>
<pre id="mirrorHtrStatus" class="muted" style="margin-top:.5rem;white-space:pre-wrap"></pre>
</section>
<div id="playerModal" class="modal" hidden>
<div class="modal-content card">
<button class="close" data-close>&times;</button>
<h2 id="modalTitle">Player</h2>
<div id="modalBody"></div>
</div>
</div>
<div id="storeModal" class="modal" hidden>
<div class="modal-content card">
<button class="close" data-close-store>&times;</button>
<h2 id="storeModalTitle">Store item</h2>
<div id="storeModalBody"></div>
</div>
</div>
</div>
<div id="toast" class="toast" hidden></div>
<script src="app.js?v=20260510-playerlogs"></script>
</body>
</html>
@@ -0,0 +1,619 @@
@import url('https://rsms.me/inter/inter.css');
* { box-sizing: border-box; }
:root {
color-scheme: dark;
--bg: #0a0c10;
--bg-grad: radial-gradient(circle at 20% 0%, #16204a 0%, #0a0c10 60%);
--panel: rgba(22, 26, 35, 0.85);
--panel-solid: #161a23;
--panel-2: #1d2230;
--panel-3: #252b3b;
--border: #2a3145;
--border-subtle: #1f2433;
--text: #eef0f5;
--text-dim: #b3b9c9;
--muted: #7a8195;
--accent: #7aa9ff;
--accent-2: #b48cff;
--accent-grad: linear-gradient(135deg, #7aa9ff 0%, #b48cff 100%);
--accent-hover: #93b9ff;
--good: #4ad28b;
--good-bg: rgba(74, 210, 139, 0.12);
--warn: #ffce5b;
--warn-bg: rgba(255, 206, 91, 0.12);
--danger: #ff6f7d;
--danger-bg: rgba(255, 111, 125, 0.12);
--info-bg: rgba(122, 169, 255, 0.12);
--radius: 10px;
--radius-lg: 14px;
--shadow-sm: 0 1px 0 rgba(255,255,255,0.05) inset, 0 2px 6px rgba(0,0,0,0.25);
--shadow: 0 1px 0 rgba(255,255,255,0.05) inset, 0 12px 32px rgba(0,0,0,0.45);
--shadow-lg: 0 1px 0 rgba(255,255,255,0.06) inset, 0 24px 60px rgba(0,0,0,0.6);
--t: 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
background-image: var(--bg-grad);
background-attachment: fixed;
color: var(--text);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-feature-settings: 'cv11', 'ss01', 'ss03';
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@supports (font-variation-settings: normal) {
body { font-family: 'Inter var', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
}
h1, h2, h3, h4 { margin: 0; letter-spacing: -0.01em; }
h1 { font-size: 22px; font-weight: 700; }
h2 { font-size: 17px; font-weight: 600; }
h3 {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
margin-bottom: 10px;
}
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
.muted { color: var(--muted); }
.error {
color: var(--danger);
margin-top: 12px;
min-height: 1em;
font-size: 13px;
}
/* ── Buttons ─────────────────────────────────────────────────────── */
button {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
padding: 7px 14px;
border-radius: 8px;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
transition: all var(--t);
line-height: 1.2;
white-space: nowrap;
}
button:hover { background: var(--panel-3); border-color: #38415a; transform: translateY(-1px); }
button:active { transform: translateY(0); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button[type="submit"], button.primary {
background: var(--accent-grad);
border-color: transparent;
color: #0a0c10;
font-weight: 600;
box-shadow: 0 4px 14px rgba(122, 169, 255, 0.25);
}
button[type="submit"]:hover, button.primary:hover {
box-shadow: 0 6px 20px rgba(122, 169, 255, 0.4);
}
button.danger {
background: linear-gradient(135deg, #ff6f7d 0%, #d94d6b 100%);
border-color: transparent;
color: #fff;
box-shadow: 0 4px 14px rgba(255, 111, 125, 0.2);
}
button.danger:hover { box-shadow: 0 6px 20px rgba(255, 111, 125, 0.35); }
button.ghost {
background: transparent;
border-color: var(--border);
color: var(--text-dim);
}
button.ghost:hover { color: var(--text); border-color: var(--accent); background: var(--info-bg); }
button.link {
background: transparent;
border: none;
color: var(--accent);
padding: 0;
text-decoration: none;
cursor: pointer;
font-size: 13px;
transition: color var(--t);
}
button.link:hover { color: var(--accent-hover); transform: none; }
/* ── Inputs ──────────────────────────────────────────────────────── */
input, textarea, select {
background: var(--panel-solid);
color: var(--text);
border: 1px solid var(--border);
padding: 8px 12px;
border-radius: 8px;
font: inherit;
font-size: 13px;
transition: all var(--t);
width: 100%;
}
input::placeholder, textarea::placeholder { color: var(--muted); }
input:hover, textarea:hover, select:hover { border-color: #3a425a; }
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(122, 169, 255, 0.15);
}
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
margin: 0;
cursor: pointer;
}
input[type="number"] { width: 100%; }
label {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
color: var(--text-dim);
font-size: 12px;
font-weight: 500;
}
label > input,
label > textarea,
label > select {
color: var(--text);
font-size: 13px;
font-weight: 400;
}
/* ── Cards ───────────────────────────────────────────────────────── */
.card {
background: var(--panel);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px;
box-shadow: var(--shadow);
}
/* ── Login ───────────────────────────────────────────────────────── */
#login {
max-width: 380px;
margin: 14vh auto;
animation: fadeUp 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#login h1 {
background: var(--accent-grad);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 4px;
}
#login p { margin: 0 0 22px; }
#login button[type="submit"] { width: 100%; padding: 10px; font-size: 14px; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── App shell ───────────────────────────────────────────────────── */
#app { animation: fadeIn 240ms ease-out; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 32px;
background: rgba(13, 16, 22, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 0;
z-index: 50;
}
header h1 {
background: var(--accent-grad);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 18px;
}
header .who {
color: var(--muted);
font-size: 13px;
display: flex;
align-items: center;
gap: 12px;
}
header #meName {
color: var(--text);
font-weight: 600;
margin: 0 4px;
}
nav {
display: flex;
gap: 4px;
padding: 12px 32px;
background: rgba(10, 12, 16, 0.6);
border-bottom: 1px solid var(--border-subtle);
overflow-x: auto;
scrollbar-width: none;
}
nav::-webkit-scrollbar { display: none; }
nav .tab {
background: transparent;
border: 1px solid transparent;
color: var(--muted);
border-radius: 8px;
font-weight: 500;
transition: all var(--t);
}
nav .tab:hover { color: var(--text-dim); transform: none; background: var(--panel-2); }
nav .tab.active {
background: var(--info-bg);
color: var(--accent);
border-color: rgba(122, 169, 255, 0.25);
}
.tab-panel {
padding: 28px 32px 80px;
max-width: 1280px;
margin: 0 auto;
animation: fadeIn 200ms ease-out;
}
.tab-panel > h2:first-child { margin-bottom: 20px; }
.row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin: 12px 0;
}
.row > input,
.row > select { flex: 1; min-width: 140px; width: auto; }
/* ── Dashboard stats ─────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin: 0 0 32px;
}
.stat {
background: var(--panel);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
padding: 18px 20px;
transition: all var(--t);
position: relative;
overflow: hidden;
}
.stat:hover { transform: translateY(-2px); border-color: var(--border); box-shadow: var(--shadow-sm); }
.stat::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--accent-grad);
opacity: 0;
transition: opacity var(--t);
}
.stat:hover::before { opacity: 1; }
.stat .label {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
.stat .value {
font-size: 28px;
font-weight: 700;
margin-top: 6px;
letter-spacing: -0.02em;
font-feature-settings: 'tnum';
}
.stat .value.good { color: var(--good); }
.stat .value.warn { color: var(--warn); }
.stat .value.danger { color: var(--danger); }
/* ── Tables ──────────────────────────────────────────────────────── */
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 12px 0 24px;
background: var(--panel);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
overflow: hidden;
}
.table th, .table td {
padding: 11px 16px;
text-align: left;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
vertical-align: middle;
}
.table th {
background: rgba(13, 16, 22, 0.6);
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
position: sticky;
top: 0;
}
.table tr:last-child td { border-bottom: none; }
.table tr { transition: background var(--t); }
.table tbody tr:hover td { background: rgba(122, 169, 255, 0.04); }
.table .actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.table .actions button {
padding: 4px 10px;
font-size: 12px;
}
/* ── Badges ──────────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
border: 1px solid transparent;
}
.badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.7;
}
.badge.no-dot::before { display: none; }
.badge.online { background: var(--good-bg); color: var(--good); border-color: rgba(74, 210, 139, 0.2); }
.badge.offline { background: rgba(139, 145, 158, 0.08); color: var(--muted); border-color: rgba(139, 145, 158, 0.18); }
.badge.admin { background: var(--info-bg); color: var(--accent); border-color: rgba(122, 169, 255, 0.2); }
.badge.banned { background: var(--danger-bg); color: var(--danger); border-color: rgba(255, 111, 125, 0.2); }
.badge-row { display: inline-flex; gap: 4px; flex-wrap: wrap; }
/* ── Modal ───────────────────────────────────────────────────────── */
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 20px;
overflow-y: auto;
z-index: 100;
animation: fadeIn 160ms ease-out;
}
/* The class-level `display: flex` above out-specifies the browser
default `[hidden] { display: none }` (both are specificity 0,0,1,0
but the class rule loads later), so without this override the
modal stays visible and shows an empty "Store item" / "Player"
shell on every page load. Force the hidden attribute to win. */
.modal[hidden] { display: none !important; }
.modal-content {
width: 100%;
max-width: 820px;
position: relative;
margin: auto;
box-shadow: var(--shadow-lg);
animation: modalIn 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
/* Override .card's translucent var(--panel) (rgba 0.85) — at
0.85 the table behind shows through, making the modal form
look like it's overlapping page content. Force the panel
solid here so the modal reads as a discrete dialog. */
background: var(--panel-solid) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
@keyframes modalIn {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal #modalTitle {
margin-bottom: 16px;
padding-right: 40px;
background: var(--accent-grad);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.modal .close {
position: absolute;
top: 16px;
right: 16px;
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--muted);
font-size: 18px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
line-height: 1;
}
.modal .close:hover { color: var(--text); background: var(--panel-3); transform: none; }
/* ── Sections inside modal ───────────────────────────────────────── */
.section {
border-top: 1px solid var(--border-subtle);
padding-top: 18px;
margin-top: 18px;
}
.section:first-child { border-top: none; padding-top: 0; margin-top: 0; }
.kvp {
display: grid;
grid-template-columns: 130px 1fr;
gap: 8px 16px;
margin: 0;
}
.kvp .k {
color: var(--muted);
font-size: 12px;
font-weight: 500;
}
.kvp > * { padding: 4px 0; }
/* ── Color swatches (hair / skin preview) ────────────────────────── */
.swatch-input {
display: flex;
gap: 8px;
align-items: stretch;
}
.swatch-input input { flex: 1; }
.swatch {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--border);
flex-shrink: 0;
background-image:
linear-gradient(45deg, #2a2f3a 25%, transparent 25%),
linear-gradient(-45deg, #2a2f3a 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #2a2f3a 75%),
linear-gradient(-45deg, transparent 75%, #2a2f3a 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
}
/* ── Toast ───────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--panel-solid);
border: 1px solid var(--border);
color: var(--text);
padding: 12px 18px;
border-radius: 10px;
box-shadow: var(--shadow);
z-index: 200;
max-width: 360px;
font-size: 13px;
animation: toastIn 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
border-left: 3px solid var(--accent);
}
.toast.error { border-left-color: var(--danger); }
.toast.good { border-left-color: var(--good); }
@keyframes toastIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
textarea {
width: 100%;
resize: vertical;
min-height: 80px;
font-family: 'Inter', -apple-system, sans-serif;
}
form { margin: 0; }
/* ── Inline-checkbox helper ──────────────────────────────────────── */
.checkbox-label {
flex-direction: row !important;
align-items: center !important;
gap: 8px !important;
margin: 0 !important;
color: var(--text-dim);
cursor: pointer;
user-select: none;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-solid);
transition: all var(--t);
font-size: 13px;
}
.checkbox-label:hover { border-color: var(--accent); color: var(--text); }
.checkbox-label input { width: auto; }
/* ── Empty state ─────────────────────────────────────────────────── */
.empty {
text-align: center;
padding: 40px 20px;
color: var(--muted);
font-size: 13px;
}
/* ── Scrollbar ───────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--panel-3); }
/* ── Numeric figures align nicely in tables ──────────────────────── */
.table td:first-child,
.table td.num { font-feature-settings: 'tnum'; color: var(--muted); }
/* ── Misc utilities ──────────────────────────────────────────────── */
.spacer { flex: 1; }
.right { margin-left: auto; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }