mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
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:
@@ -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>×</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>×</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; }
|
||||
@@ -0,0 +1,244 @@
|
||||
// DorkNet feed — public photo gallery. Reads from same-origin
|
||||
// /api/photos/v1/* (PhotosController is dual-hosted on api.rec.net and
|
||||
// feed.rec.net so no CORS dance). Hash routing keeps it a single page:
|
||||
// #/ → feed grid
|
||||
// #/p/123 → photo detail
|
||||
// #/u/456 → uploader profile
|
||||
|
||||
const API = '/api/photos/v1';
|
||||
|
||||
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 === '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;
|
||||
};
|
||||
|
||||
async function api(path) {
|
||||
const res = await fetch(API + path);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return '';
|
||||
const now = new Date();
|
||||
const diff = (now - d) / 1000;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
if (!name) return '?';
|
||||
const parts = name.split(/[\s_]/).filter(Boolean);
|
||||
return (parts[0]?.[0] || '') + (parts[1]?.[0] || '');
|
||||
}
|
||||
|
||||
function showView(name) {
|
||||
document.querySelectorAll('.view').forEach(v => v.hidden = v.id !== `${name}-view`);
|
||||
document.querySelectorAll('.nav-link').forEach(l => l.classList.toggle('active', l.dataset.route === 'feed' && name === 'feed'));
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// ── Router ───────────────────────────────────────────────────────────
|
||||
|
||||
window.addEventListener('hashchange', route);
|
||||
window.addEventListener('DOMContentLoaded', route);
|
||||
|
||||
function goFeed() { location.hash = '#/'; }
|
||||
|
||||
function route() {
|
||||
const h = location.hash.slice(1) || '/';
|
||||
const photoMatch = h.match(/^\/p\/(\d+)$/);
|
||||
const userMatch = h.match(/^\/u\/(\d+)$/);
|
||||
if (photoMatch) renderPhoto(parseInt(photoMatch[1], 10));
|
||||
else if (userMatch) renderProfile(parseInt(userMatch[1], 10));
|
||||
else renderFeed();
|
||||
}
|
||||
|
||||
// ── Feed view ────────────────────────────────────────────────────────
|
||||
|
||||
let feedSkip = 0;
|
||||
const PAGE = 24;
|
||||
|
||||
async function renderFeed() {
|
||||
showView('feed');
|
||||
feedSkip = 0;
|
||||
$('grid').replaceChildren(...skeletonCards(6));
|
||||
$('empty').hidden = true;
|
||||
$('loadMore').hidden = true;
|
||||
try {
|
||||
const photos = await api(`/feed?take=${PAGE}&skip=0`);
|
||||
$('grid').replaceChildren();
|
||||
if (photos.length === 0) {
|
||||
$('empty').hidden = false;
|
||||
return;
|
||||
}
|
||||
for (const p of photos) $('grid').append(photoCard(p));
|
||||
feedSkip = photos.length;
|
||||
if (photos.length === PAGE) $('loadMore').hidden = false;
|
||||
} catch (err) {
|
||||
$('grid').replaceChildren();
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$('loadMoreBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
const photos = await api(`/feed?take=${PAGE}&skip=${feedSkip}`);
|
||||
for (const p of photos) $('grid').append(photoCard(p));
|
||||
feedSkip += photos.length;
|
||||
if (photos.length < PAGE) $('loadMore').hidden = true;
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
});
|
||||
|
||||
function skeletonCards(n) {
|
||||
return Array.from({ length: n }, () => {
|
||||
const card = el('div', { class: 'photo-card' });
|
||||
const h = 180 + Math.floor(Math.random() * 220);
|
||||
card.append(el('div', { class: 'skeleton', style: `height: ${h}px;` }));
|
||||
card.append(el('div', { class: 'photo-meta' },
|
||||
el('div', { class: 'skeleton', style: 'height: 14px; width: 60%; margin-bottom: 8px;' }),
|
||||
el('div', { class: 'skeleton', style: 'height: 12px; width: 90%;' }),
|
||||
));
|
||||
return card;
|
||||
});
|
||||
}
|
||||
|
||||
function photoCard(p) {
|
||||
const card = el('a', {
|
||||
class: 'photo-card',
|
||||
href: `#/p/${p.Id}`,
|
||||
});
|
||||
const img = el('img', {
|
||||
class: 'photo-img',
|
||||
src: p.ImageUrl,
|
||||
alt: p.Caption || `Photo by ${p.UploaderDisplayName}`,
|
||||
loading: 'lazy',
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
img.style.display = 'none';
|
||||
});
|
||||
card.append(img);
|
||||
card.append(el('div', { class: 'photo-meta' },
|
||||
el('div', { class: 'uploader' },
|
||||
el('div', { class: 'uploader-avatar' }, initials(p.UploaderDisplayName)),
|
||||
el('span', {}, p.UploaderDisplayName),
|
||||
),
|
||||
p.Caption ? el('div', { class: 'caption' }, p.Caption) : null,
|
||||
el('div', { class: 'photo-foot' },
|
||||
el('span', { class: 'cheers' }, `♥ ${p.CheerCount.toLocaleString()}`),
|
||||
p.RoomName ? el('span', { class: 'room' }, `^${p.RoomName}`) : el('span', { class: 'time' }, fmtDate(p.CreatedAt)),
|
||||
),
|
||||
));
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Photo detail view ────────────────────────────────────────────────
|
||||
|
||||
async function renderPhoto(id) {
|
||||
showView('photo');
|
||||
$('photoDetail').replaceChildren(el('div', { class: 'skeleton', style: 'height: 480px;' }));
|
||||
try {
|
||||
const p = await api(`/${id}`);
|
||||
const tagged = (p.TaggedPlayerIds || []).slice(0, 8);
|
||||
$('photoDetail').replaceChildren(el('div', { class: 'photo-detail' },
|
||||
el('div', { class: 'image-wrap' },
|
||||
el('img', { src: p.ImageUrl, alt: p.Caption || `Photo #${p.Id}` })),
|
||||
el('div', { class: 'info' },
|
||||
el('h2', {}, `Photo #${p.Id}`),
|
||||
el('div', { class: 'caption-full' }, p.Caption || ''),
|
||||
el('div', { class: 'meta-row' },
|
||||
el('span', { class: 'k' }, 'By'),
|
||||
el('a', { class: 'uploader-link', href: `#/u/${p.UploaderPlayerId}` },
|
||||
el('div', { class: 'uploader-avatar' }, initials(p.UploaderDisplayName)),
|
||||
el('span', {}, p.UploaderDisplayName),
|
||||
),
|
||||
),
|
||||
p.RoomName
|
||||
? el('div', { class: 'meta-row' },
|
||||
el('span', { class: 'k' }, 'In'),
|
||||
el('span', { class: 'room-pill' }, `^${p.RoomName}`))
|
||||
: null,
|
||||
el('div', { class: 'meta-row' },
|
||||
el('span', { class: 'k' }, 'Posted'),
|
||||
el('span', {}, fmtDate(p.CreatedAt) + ' (' + new Date(p.CreatedAt).toLocaleString() + ')'),
|
||||
),
|
||||
tagged.length
|
||||
? el('div', { class: 'meta-row' },
|
||||
el('span', { class: 'k' }, 'Tagged'),
|
||||
el('span', {}, tagged.length + ' player' + (tagged.length === 1 ? '' : 's')))
|
||||
: null,
|
||||
el('div', { class: 'stats' },
|
||||
el('div', { class: 'stat-pill cheers' },
|
||||
el('div', { class: 'v' }, p.CheerCount.toLocaleString()),
|
||||
el('div', { class: 'l' }, 'cheers')),
|
||||
el('div', { class: 'stat-pill' },
|
||||
el('div', { class: 'v' }, p.ViewCount.toLocaleString()),
|
||||
el('div', { class: 'l' }, 'views')),
|
||||
),
|
||||
),
|
||||
));
|
||||
} catch (err) {
|
||||
$('photoDetail').replaceChildren(el('div', { class: 'empty' },
|
||||
el('p', {}, 'Photo not found.')));
|
||||
}
|
||||
}
|
||||
|
||||
$('backBtn').addEventListener('click', goFeed);
|
||||
|
||||
// ── Profile view ─────────────────────────────────────────────────────
|
||||
|
||||
async function renderProfile(playerId) {
|
||||
showView('profile');
|
||||
$('profileDetail').replaceChildren(el('div', { class: 'skeleton', style: 'height: 100px;' }));
|
||||
try {
|
||||
const photos = await api(`/by/${playerId}?take=60`);
|
||||
const uploaderName = photos[0]?.UploaderDisplayName || `Player ${playerId}`;
|
||||
const uploaderUsername = photos[0]?.UploaderUsername || '';
|
||||
$('profileDetail').replaceChildren(
|
||||
el('div', { class: 'profile-header' },
|
||||
el('div', { class: 'avatar' }, initials(uploaderName)),
|
||||
el('div', {},
|
||||
el('h2', {}, uploaderName),
|
||||
el('div', { class: 'username' },
|
||||
uploaderUsername ? `@${uploaderUsername} · ` : '',
|
||||
`${photos.length} photo${photos.length === 1 ? '' : 's'}`),
|
||||
),
|
||||
),
|
||||
(() => {
|
||||
const grid = el('div', { class: 'grid' });
|
||||
if (photos.length === 0)
|
||||
grid.append(el('div', { class: 'empty' },
|
||||
el('p', {}, 'No photos posted yet.')));
|
||||
else
|
||||
for (const p of photos) grid.append(photoCard(p));
|
||||
return grid;
|
||||
})(),
|
||||
);
|
||||
} catch (err) {
|
||||
$('profileDetail').replaceChildren(el('div', { class: 'empty' },
|
||||
el('p', {}, 'Profile not found.')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DorkNet — Feed</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-mark">●</span>
|
||||
<span class="logo-text">DorkNet</span>
|
||||
</a>
|
||||
<nav class="site-nav">
|
||||
<a href="/" class="nav-link active" data-route="feed">Feed</a>
|
||||
<a href="/?room" class="nav-link" data-route="rooms">Rooms</a>
|
||||
<a href="/?about" class="nav-link" data-route="about">About</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="feed-view" class="view">
|
||||
<div class="hero">
|
||||
<h1>The Feed</h1>
|
||||
<p class="muted">Photos taken by players around the server, newest first.</p>
|
||||
</div>
|
||||
<div id="grid" class="grid"></div>
|
||||
<div id="loadMore" class="load-more" hidden>
|
||||
<button id="loadMoreBtn" class="primary">Load more</button>
|
||||
</div>
|
||||
<div id="empty" class="empty" hidden>
|
||||
<p>No photos yet — be the first to share one from the in-game camera.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="photo-view" class="view" hidden>
|
||||
<button id="backBtn" class="ghost back">← back to feed</button>
|
||||
<div id="photoDetail"></div>
|
||||
</section>
|
||||
|
||||
<section id="profile-view" class="view" hidden>
|
||||
<button class="ghost back" onclick="goFeed()">← back to feed</button>
|
||||
<div id="profileDetail"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>DorkNet · a private Rec Room server · <a href="https://github.com/Alexa-RR/Dorknet">GitHub</a></p>
|
||||
</footer>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,448 @@
|
||||
@import url('https://rsms.me/inter/inter.css');
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #07080b;
|
||||
--bg-grad: radial-gradient(circle at 20% -10%, #1d1748 0%, #07080b 55%);
|
||||
--panel: rgba(20, 22, 30, 0.7);
|
||||
--panel-2: #161922;
|
||||
--panel-3: #1f2330;
|
||||
--border: #262b3a;
|
||||
--border-subtle: #1a1e29;
|
||||
--text: #f0f2f8;
|
||||
--text-dim: #b0b6c8;
|
||||
--muted: #757b8e;
|
||||
--accent: #ff6ec7;
|
||||
--accent-2: #7aa9ff;
|
||||
--accent-grad: linear-gradient(135deg, #ff6ec7 0%, #b48cff 50%, #7aa9ff 100%);
|
||||
--good: #4ad28b;
|
||||
--danger: #ff6f7d;
|
||||
--shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
--t: 180ms 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-size: 14px;
|
||||
line-height: 1.55;
|
||||
-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; }
|
||||
}
|
||||
|
||||
a { color: var(--accent-2); text-decoration: none; transition: color var(--t); }
|
||||
a:hover { color: #a3c4ff; }
|
||||
|
||||
button {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all var(--t);
|
||||
}
|
||||
button:hover { background: var(--panel-3); border-color: #353c54; transform: translateY(-1px); }
|
||||
button.primary {
|
||||
background: var(--accent-grad);
|
||||
border-color: transparent;
|
||||
color: #07080b;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 18px rgba(255, 110, 199, 0.25);
|
||||
}
|
||||
button.primary:hover { box-shadow: 0 6px 24px rgba(255, 110, 199, 0.4); }
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
button.ghost:hover { color: var(--text); border-color: var(--accent); background: rgba(255,110,199,0.06); }
|
||||
|
||||
/* ── Site shell ──────────────────────────────────────────────────── */
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 22px 36px;
|
||||
background: rgba(7, 8, 11, 0.7);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
.logo:hover { color: var(--text); }
|
||||
.logo-mark {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-grad);
|
||||
color: transparent;
|
||||
box-shadow: 0 4px 14px rgba(255, 110, 199, 0.5);
|
||||
}
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-link {
|
||||
color: var(--muted);
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all var(--t);
|
||||
}
|
||||
.nav-link:hover { color: var(--text); background: var(--panel-2); }
|
||||
.nav-link.active { color: var(--text); background: var(--panel-2); }
|
||||
|
||||
main { max-width: 1280px; margin: 0 auto; padding: 32px 36px 80px; }
|
||||
|
||||
.site-footer {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: 80px;
|
||||
}
|
||||
.site-footer a { color: var(--muted); }
|
||||
.site-footer a:hover { color: var(--text-dim); }
|
||||
|
||||
/* ── Hero ────────────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: var(--accent-grad);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.hero p { margin: 0; color: var(--text-dim); font-size: 15px; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
/* ── Photo grid (masonry-ish via CSS columns) ────────────────────── */
|
||||
|
||||
.grid {
|
||||
column-count: 3;
|
||||
column-gap: 16px;
|
||||
}
|
||||
@media (max-width: 1024px) { .grid { column-count: 2; } }
|
||||
@media (max-width: 600px) { .grid { column-count: 1; } }
|
||||
|
||||
.photo-card {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 16px;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
transition: all var(--t);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
color: inherit;
|
||||
animation: fadeIn 280ms ease-out;
|
||||
}
|
||||
.photo-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.photo-card .photo-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: var(--panel-2);
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
.photo-card .photo-meta {
|
||||
padding: 12px 14px 14px;
|
||||
}
|
||||
.photo-card .uploader {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
margin: 0 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.photo-card .uploader-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-grad);
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #07080b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.photo-card .caption {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
margin: 6px 0 8px;
|
||||
line-height: 1.45;
|
||||
/* clamp to 3 lines on the card */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.photo-card .photo-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.photo-card .photo-foot .cheers {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.photo-card .photo-foot .room {
|
||||
background: var(--panel-3);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-dim);
|
||||
max-width: 60%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Photo detail ────────────────────────────────────────────────── */
|
||||
|
||||
.back {
|
||||
margin-bottom: 18px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.photo-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.photo-detail { grid-template-columns: 1fr; }
|
||||
}
|
||||
.photo-detail .image-wrap {
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.photo-detail .image-wrap img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.photo-detail .info {
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
padding: 22px;
|
||||
position: sticky;
|
||||
top: 96px;
|
||||
}
|
||||
.photo-detail .info h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
background: var(--accent-grad);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.photo-detail .info .caption-full {
|
||||
color: var(--text);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.photo-detail .info .caption-full:empty::before {
|
||||
content: 'No caption.';
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.photo-detail .info .meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.photo-detail .info .meta-row:first-of-type { border-top: none; }
|
||||
.photo-detail .info .meta-row .k { color: var(--muted); min-width: 80px; }
|
||||
.photo-detail .info .uploader-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.photo-detail .info .uploader-link:hover { color: var(--accent); }
|
||||
.photo-detail .info .room-pill {
|
||||
background: var(--panel-3);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.photo-detail .info .stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.photo-detail .info .stat-pill {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border-subtle);
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.photo-detail .info .stat-pill .v {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.photo-detail .info .stat-pill .l {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.photo-detail .info .stat-pill.cheers .v { color: var(--accent); }
|
||||
|
||||
/* ── Profile view ────────────────────────────────────────────────── */
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.profile-header .avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-grad);
|
||||
color: #07080b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.profile-header h2 { margin: 0 0 4px; font-size: 22px; }
|
||||
.profile-header .username { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* ── Empty / load-more ───────────────────────────────────────────── */
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 24px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty p { margin: 0; font-size: 14px; }
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
margin: 28px 0;
|
||||
}
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────────────────── */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 200;
|
||||
font-size: 13px;
|
||||
border-left: 3px solid var(--accent);
|
||||
animation: toastIn 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.toast.error { border-left-color: var(--danger); }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
::-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); }
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--panel-2) 0%, var(--panel-3) 50%, var(--panel-2) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
Reference in New Issue
Block a user