mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -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,323 @@
|
||||
import { clearSession, getToken, notifyAuthChange } from './auth';
|
||||
|
||||
const API_BASE = '/api/admin/v1';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string, public body?: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
// For multipart/form-data uploads — pass the FormData and skip JSON encoding.
|
||||
formData?: FormData;
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(path: string, opts: ApiOptions = {}): Promise<T> {
|
||||
const headers = new Headers(opts.headers ?? {});
|
||||
const token = getToken();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.formData) {
|
||||
body = opts.formData;
|
||||
} else if (opts.body !== undefined && opts.body !== null) {
|
||||
if (typeof opts.body === 'string') {
|
||||
body = opts.body;
|
||||
} else {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 15000);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(API_BASE + path, {
|
||||
...opts,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
throw new ApiError(0, `request timed out (${opts.timeoutMs ?? 15000}ms)`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearSession();
|
||||
notifyAuthChange();
|
||||
throw new ApiError(401, 'Unauthorized — sign in again');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`;
|
||||
let bodyJson: unknown;
|
||||
try {
|
||||
bodyJson = await res.json();
|
||||
const err = bodyJson as { error?: string; error_description?: string; message?: string };
|
||||
if (err?.error) message = err.error + (err.error_description ? ` (${err.error_description})` : '');
|
||||
else if (err?.message) message = err.message;
|
||||
} catch { /* leave message as status */ }
|
||||
throw new ApiError(res.status, message, bodyJson);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (ct.includes('application/json')) {
|
||||
const raw = await res.json();
|
||||
return camelizeKeys(raw) as T;
|
||||
}
|
||||
return (await res.text()) as T;
|
||||
}
|
||||
|
||||
// Server responses are PascalCase because Program.cs sets
|
||||
// PropertyNamingPolicy=null project-wide (the old Rec Room watch
|
||||
// depends on exact wire keys). The admin SPA is the only place we
|
||||
// control both ends, so we camelCase on ingress so TS code stays
|
||||
// idiomatic. Outbound POST bodies don't need the inverse — ASP.NET
|
||||
// Core's JSON binder has PropertyNameCaseInsensitive=true by default,
|
||||
// so camelCase request fields bind to PascalCase record properties
|
||||
// fine.
|
||||
function camelizeKey(s: string): string {
|
||||
if (s.length === 0) return s;
|
||||
// Leave already-lower (OAuth-style snake_case keys like access_token)
|
||||
// and single-character keys alone — only flip the leading capital.
|
||||
return s[0] === s[0].toLowerCase() ? s : s[0].toLowerCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function camelizeKeys(input: unknown): unknown {
|
||||
if (Array.isArray(input)) return input.map(camelizeKeys);
|
||||
if (input !== null && typeof input === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
|
||||
out[camelizeKey(k)] = camelizeKeys(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
// Convenience shorthands so callers don't have to spell out the options object.
|
||||
export const get = <T = unknown>(path: string) => api<T>(path);
|
||||
export const post = <T = unknown>(path: string, body?: unknown) => api<T>(path, { method: 'POST', body });
|
||||
export const put = <T = unknown>(path: string, body?: unknown) => api<T>(path, { method: 'PUT', body });
|
||||
export const del = <T = unknown>(path: string, body?: unknown) => api<T>(path, { method: 'DELETE', body });
|
||||
|
||||
// Upload progress callback fires repeatedly during the request body
|
||||
// send (request bytes), then once with {total, loaded: total} just
|
||||
// before the server starts processing. fetch() doesn't expose request
|
||||
// progress at all, so we fall back to XHR for this one helper.
|
||||
export interface UploadProgress { loaded: number; total: number; percent: number; phase?: string }
|
||||
|
||||
// Chunk size for chunked uploads. Cloudflare's free/pro plans cap
|
||||
// request bodies at 100 MB end-to-end (Tunnel inherits the same
|
||||
// limit), so any chunk we send has to stay below that. 50 MB leaves
|
||||
// generous headroom for the multipart envelope, TLS framing, and
|
||||
// HTTP/2 stream buffers.
|
||||
export const ZIP_CHUNK_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export async function uploadWithProgress<T = unknown>(
|
||||
path: string,
|
||||
formData: FormData,
|
||||
onProgress?: (p: UploadProgress) => void,
|
||||
timeoutMs = 30 * 60_000,
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/admin/v1' + path, true);
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.timeout = timeoutMs;
|
||||
xhr.responseType = 'text';
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (!onProgress) return;
|
||||
const total = e.lengthComputable ? e.total : 0;
|
||||
const percent = total > 0 ? Math.round((e.loaded / total) * 100) : 0;
|
||||
onProgress({ loaded: e.loaded, total, percent });
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
clearSession();
|
||||
notifyAuthChange();
|
||||
reject(new ApiError(401, 'Unauthorized — sign in again'));
|
||||
return;
|
||||
}
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
let msg = `${xhr.status} ${xhr.statusText}`;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText) as { error?: string; message?: string };
|
||||
if (body?.error) msg = body.error;
|
||||
else if (body?.message) msg = body.message;
|
||||
} catch { /* keep status text */ }
|
||||
reject(new ApiError(xhr.status, msg));
|
||||
return;
|
||||
}
|
||||
// Parse response with the same camelize behavior as fetch path.
|
||||
try {
|
||||
const ct = xhr.getResponseHeader('content-type') ?? '';
|
||||
if (ct.includes('application/json') && xhr.responseText) {
|
||||
const raw = JSON.parse(xhr.responseText);
|
||||
resolve(camelizeKeys(raw) as T);
|
||||
} else {
|
||||
resolve(xhr.responseText as T);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new ApiError(0, 'network error'));
|
||||
xhr.ontimeout = () => reject(new ApiError(0, `request timed out (${timeoutMs}ms)`));
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
// Chunked upload for the zip importer. Splits a File into
|
||||
// ZIP_CHUNK_BYTES-sized pieces with Blob.slice(), POSTs each piece's
|
||||
// raw bytes to /zip-upload-chunk?offset=N (no multipart wrapper —
|
||||
// keeps the bytes-on-wire identical to the in-memory slice), then
|
||||
// calls /zip-upload-finalize once everything's uploaded. Progress
|
||||
// callback fires per chunk-completion as well as during each chunk's
|
||||
// upload phase, so the UI bar advances smoothly through a multi-GB
|
||||
// archive instead of stepping in 50 MB jumps.
|
||||
export interface ZipImportOptions {
|
||||
/// Top-level archive folders (e.g. "Rooms/Room_3163127_YarrHarrHeist_…"
|
||||
/// or "Inventions/Invention_…") to actually import. Leave undefined
|
||||
/// or empty to import everything in the archive — the SPA's preview
|
||||
/// builds these lists from the room/invention checkboxes.
|
||||
selectedRoomFolders?: string[];
|
||||
selectedInventionFolders?: string[];
|
||||
/// Round-trip the imported .room blobs through the 2020 protobuf
|
||||
/// schema before persisting. Defaults off because the re-encode
|
||||
/// currently crashes the watch on load; the SPA exposes the toggle
|
||||
/// in the importer's options panel for cases where the admin wants
|
||||
/// to opt back in.
|
||||
normalizeBlobs?: boolean;
|
||||
}
|
||||
|
||||
export async function uploadZipInChunks<T = unknown>(
|
||||
file: File,
|
||||
creatorPlayerId: number,
|
||||
onProgress: (p: UploadProgress) => void,
|
||||
timeoutMs = 30 * 60_000,
|
||||
options: ZipImportOptions = {},
|
||||
): Promise<T> {
|
||||
// 1. Init — server allocates a temp file and hands back an uploadId.
|
||||
const init = await api<{ uploadId: string }>('/rooms/zip-upload-init', {
|
||||
method: 'POST',
|
||||
body: { FileName: file.name, TotalBytes: file.size },
|
||||
});
|
||||
const uploadId = init.uploadId;
|
||||
|
||||
try {
|
||||
let uploadedSoFar = 0;
|
||||
for (let offset = 0; offset < file.size; offset += ZIP_CHUNK_BYTES) {
|
||||
const end = Math.min(offset + ZIP_CHUNK_BYTES, file.size);
|
||||
const chunk = file.slice(offset, end);
|
||||
const chunkStart = offset;
|
||||
const chunkSize = end - offset;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const token = getToken();
|
||||
xhr.open('POST', `/api/admin/v1/rooms/zip-upload-chunk/${uploadId}?offset=${chunkStart}`, true);
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.timeout = timeoutMs;
|
||||
xhr.responseType = 'text';
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
const loaded = uploadedSoFar + (e.lengthComputable ? e.loaded : 0);
|
||||
const percent = file.size > 0 ? Math.round((loaded / file.size) * 100) : 0;
|
||||
onProgress({ loaded, total: file.size, percent });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 401) {
|
||||
clearSession(); notifyAuthChange();
|
||||
reject(new ApiError(401, 'Unauthorized — sign in again'));
|
||||
return;
|
||||
}
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
let msg = `${xhr.status} ${xhr.statusText}`;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText) as { error?: string; message?: string };
|
||||
if (body?.error) msg = body.error;
|
||||
else if (body?.message) msg = body.message;
|
||||
} catch { /* keep status */ }
|
||||
reject(new ApiError(xhr.status, msg));
|
||||
return;
|
||||
}
|
||||
uploadedSoFar += chunkSize;
|
||||
onProgress({ loaded: uploadedSoFar, total: file.size, percent: Math.round((uploadedSoFar / file.size) * 100) });
|
||||
resolve();
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, `chunk @${chunkStart} network error`));
|
||||
xhr.ontimeout = () => reject(new ApiError(0, `chunk @${chunkStart} timed out`));
|
||||
xhr.send(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Finalize — server kicks off the import in a background task
|
||||
// and returns 202 immediately with a jobId. Multi-GB archives
|
||||
// take several minutes to process and used to 524 through
|
||||
// Cloudflare on the synchronous finalize path; the new flow
|
||||
// lets us poll for progress without holding the HTTP connection
|
||||
// open the entire time.
|
||||
const finalize = await api<{ jobId: string; state: string }>(
|
||||
`/rooms/zip-upload-finalize/${uploadId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
CreatorPlayerId: creatorPlayerId,
|
||||
SelectedRoomFolders: options.selectedRoomFolders ?? null,
|
||||
SelectedInventionFolders: options.selectedInventionFolders ?? null,
|
||||
NormalizeBlobs: options.normalizeBlobs ?? false,
|
||||
},
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
// 3. Poll status — every 3 seconds is a comfortable cadence:
|
||||
// fast enough to feel responsive in the UI, slow enough that
|
||||
// a 30-minute import doesn't generate hundreds of requests.
|
||||
// onProgress doubles as the "phase text" channel during this
|
||||
// stage by setting percent=100 and replaying the server's
|
||||
// phase string through the `phase` property.
|
||||
const jobId = finalize.jobId;
|
||||
const pollIntervalMs = 3_000;
|
||||
const pollDeadline = Date.now() + timeoutMs;
|
||||
while (true) {
|
||||
if (Date.now() > pollDeadline)
|
||||
throw new ApiError(0, `import job ${jobId} did not complete within ${Math.round(timeoutMs / 1000)}s`);
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
const status = await api<{
|
||||
state: string;
|
||||
phase: string | null;
|
||||
result: T | null;
|
||||
error: string | null;
|
||||
elapsedSeconds: number;
|
||||
}>(`/rooms/zip-import-status/${jobId}`, { method: 'GET', timeoutMs: 30_000 });
|
||||
// Surface server-side phase to the UI via onProgress.
|
||||
onProgress({ loaded: file.size, total: file.size, percent: 100, phase: status.phase ?? undefined });
|
||||
if (status.state === 'done') return status.result as T;
|
||||
if (status.state === 'failed')
|
||||
throw new ApiError(0, status.error ?? `import job ${jobId} failed`);
|
||||
// else: still running, loop.
|
||||
}
|
||||
} catch (err) {
|
||||
// Best-effort cleanup of the temp file on the server. The session
|
||||
// also auto-expires after 2 hours, so a missed abort isn't fatal.
|
||||
try { await api(`/rooms/zip-upload-abort/${uploadId}`, { method: 'DELETE' }); } catch { /* swallow */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Token + identity storage. The server's same-origin AdminLoginController
|
||||
// hands back an access_token plus a small identity blob; we stash both
|
||||
// in localStorage and re-hydrate on page load so a refresh doesn't kick
|
||||
// the admin back to the login screen.
|
||||
|
||||
const TOKEN_KEY = 'dorknet.admin.token';
|
||||
const ME_KEY = 'dorknet.admin.me';
|
||||
|
||||
export interface AdminMe {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
// getMe is read on every render by useSyncExternalStore. Re-parsing
|
||||
// localStorage each call would return a fresh object reference even
|
||||
// when the underlying data hasn't changed, which React detects as a
|
||||
// state change and re-renders forever (React error #185). Cache by
|
||||
// the raw string so consecutive identical reads return the same
|
||||
// AdminMe instance and the snapshot stays stable.
|
||||
let cachedMeRaw: string | null | undefined;
|
||||
let cachedMe: AdminMe | null = null;
|
||||
export function getMe(): AdminMe | null {
|
||||
const raw = localStorage.getItem(ME_KEY);
|
||||
if (raw === cachedMeRaw) return cachedMe;
|
||||
cachedMeRaw = raw;
|
||||
if (!raw) { cachedMe = null; return null; }
|
||||
try { cachedMe = JSON.parse(raw) as AdminMe; }
|
||||
catch { cachedMe = null; }
|
||||
return cachedMe;
|
||||
}
|
||||
|
||||
export function setSession(token: string, me: AdminMe): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(ME_KEY, JSON.stringify(me));
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(ME_KEY);
|
||||
}
|
||||
|
||||
// Simple subscription so components can react to login/logout without
|
||||
// pulling in a state lib. Anything that calls getToken() in render and
|
||||
// wants live updates can useSyncExternalStore against this.
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
export function subscribeAuth(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
export function notifyAuthChange(): void {
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Chromium gate. The zip importer uploads multi-hundred-megabyte
|
||||
// FormData bodies and uses File / Blob slicing patterns that are
|
||||
// effectively only stable on Chromium right now:
|
||||
// * Firefox's xhr.upload.progress + multipart streaming has buffer
|
||||
// issues over ~500 MB.
|
||||
// * Safari has a long-standing bug where the FormData clone for
|
||||
// large bodies hits a memory ceiling and aborts mid-upload.
|
||||
//
|
||||
// Easier to gate the importer than to chase per-browser workarounds.
|
||||
// Other admin pages don't have this constraint, so the warning only
|
||||
// shows on the import flow.
|
||||
|
||||
interface NavigatorWithUaData extends Navigator {
|
||||
userAgentData?: { brands: Array<{ brand: string; version: string }> };
|
||||
}
|
||||
|
||||
export function isChromium(): boolean {
|
||||
if (typeof navigator === 'undefined') return true; // SSR / tests
|
||||
// userAgentData is currently only implemented in Chromium-based
|
||||
// browsers — Firefox/Safari haven't shipped it. If it's present,
|
||||
// we're on Chromium.
|
||||
if ((navigator as NavigatorWithUaData).userAgentData) return true;
|
||||
// Fallback UA sniff for Chromium-derived browsers that haven't
|
||||
// exposed userAgentData yet (e.g. older Edge/Brave builds).
|
||||
const ua = navigator.userAgent;
|
||||
return /Chrome\/|Chromium\/|Edg\//.test(ua) && !/Firefox\/|FxiOS\//.test(ua);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Display helpers used across pages. Kept here so we render dates,
|
||||
// durations, and numeric quantities the same way everywhere — a player
|
||||
// table and a player detail page should not disagree about how to
|
||||
// format "5 days ago".
|
||||
|
||||
export function relativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
if (!Number.isFinite(t)) return '—';
|
||||
const diffSec = Math.round((Date.now() - t) / 1000);
|
||||
const abs = Math.abs(diffSec);
|
||||
if (abs < 45) return diffSec >= 0 ? 'just now' : 'in a moment';
|
||||
const units: Array<[number, string]> = [
|
||||
[60, 'sec'],
|
||||
[60, 'min'],
|
||||
[24, 'hr'],
|
||||
[7, 'day'],
|
||||
[4.345, 'wk'],
|
||||
[12, 'mo'],
|
||||
[Infinity, 'yr'],
|
||||
];
|
||||
let v = abs;
|
||||
let label = 'sec';
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
const [step, name] = units[i];
|
||||
if (v < step) { label = name; break; }
|
||||
v = v / step;
|
||||
label = units[i + 1]?.[1] ?? 'yr';
|
||||
}
|
||||
const rounded = v >= 10 ? Math.round(v) : Math.round(v * 10) / 10;
|
||||
return diffSec >= 0 ? `${rounded} ${label} ago` : `in ${rounded} ${label}`;
|
||||
}
|
||||
|
||||
export function absoluteTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (!Number.isFinite(d.getTime())) return '—';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export function num(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function clip(s: string | null | undefined, max = 80): string {
|
||||
if (!s) return '';
|
||||
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
// Currency type IDs follow the canonical Rec Room enum:
|
||||
// 1 = Tokens, 2 = Coins (RecRoom currency).
|
||||
export function currencyName(type: number): string {
|
||||
switch (type) {
|
||||
case 1: return 'Tokens';
|
||||
case 2: return 'Coins';
|
||||
default: return `Currency #${type}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Wire shapes returned by AdminController. Mirrors the anonymous-object
|
||||
// projections in DorkNet.Server/Controllers/Admin/AdminController.cs.
|
||||
// Loosely typed where the server returns dynamic structures; tightened
|
||||
// to specific fields where we render columns.
|
||||
|
||||
export interface Player {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
isAdmin: boolean;
|
||||
isDeveloper: boolean;
|
||||
isCommunityTeam: boolean;
|
||||
isVerified: boolean;
|
||||
isJunior: boolean;
|
||||
bannedUntil: string | null;
|
||||
lastIp: string | null;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
level: number;
|
||||
xp: number;
|
||||
profileImageName: string | null;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerDetail extends Player {
|
||||
bio: string | null;
|
||||
balances: Array<{ currencyType: number; balance: number }>;
|
||||
avatar: {
|
||||
outfitSelections: string | null;
|
||||
hairColor: string | null;
|
||||
skinColor: string | null;
|
||||
faceFeatures: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// img.* CDN URL for a player's stored profile image — same path the
|
||||
// 2020 watch uses (cropSquare=1 produces a face-zoomed square, sig
|
||||
// matches the host's signing key id).
|
||||
export function profileImageUrl(name: string | null | undefined, width = 96): string | null {
|
||||
if (!name) return null;
|
||||
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
|
||||
return `https://img.${apex}/${encodeURIComponent(name)}?width=${width}&cropSquare=1&sig=p1`;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: number;
|
||||
name: string;
|
||||
isAGRoom: boolean;
|
||||
isDormRoom: boolean;
|
||||
creatorPlayerId: number;
|
||||
blobCount: number;
|
||||
}
|
||||
|
||||
// RoomRole.Role enum (server-side RoomRoleEntity.Role):
|
||||
// 0 = CoOwner, 1 = Moderator, 2 = Host.
|
||||
// Accepted=true ⇒ surfaces in CoOwners / Moderators / Hosts arrays.
|
||||
// Accepted=false ⇒ surfaces in the Invited* counterparts (pending grant).
|
||||
export interface RoomRoleGrant {
|
||||
id: number;
|
||||
playerId: number;
|
||||
role: 0 | 1 | 2;
|
||||
accepted: boolean;
|
||||
grantedByPlayerId: number | null;
|
||||
grantedAt: string;
|
||||
player: {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
profileImageName: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Per-room live Photon instance — GET /api/admin/v1/rooms/{id}/instances.
|
||||
// masterPlayerId is a best-effort proxy for the Photon master client
|
||||
// (server doesn't see ActorNumbers; we use dorm-creator-if-present
|
||||
// else lowest-pid). The matching participant has isMaster=true so the
|
||||
// UI can tag a row without recomputing.
|
||||
export interface RoomInstance {
|
||||
roomInstanceId: number;
|
||||
roomId: number;
|
||||
subRoomId: number;
|
||||
roomName: string;
|
||||
photonRoomId: string;
|
||||
photonRegionId: string;
|
||||
location: string;
|
||||
maxCapacity: number;
|
||||
isPrivate: boolean;
|
||||
masterPlayerId: number;
|
||||
participants: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
isMaster: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Full per-room detail returned by GET /api/admin/v1/rooms/{id}. The
|
||||
// admin SPA's per-room detail page consumes this in one round trip.
|
||||
export interface RoomDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
imageName: string;
|
||||
state: number;
|
||||
accessibility: number;
|
||||
isAGRoom: boolean;
|
||||
isDormRoom: boolean;
|
||||
cloningAllowed: boolean;
|
||||
supportsLevelVoting: boolean;
|
||||
supportsVRLow: boolean;
|
||||
supportsMobile: boolean;
|
||||
supportsScreens: boolean;
|
||||
supportsWalkVR: boolean;
|
||||
supportsTeleportVR: boolean;
|
||||
allowsJuniors: boolean;
|
||||
disableMicAutoMute: boolean;
|
||||
roomWarningMask: number;
|
||||
customRoomWarning: string;
|
||||
tagsCsv: string;
|
||||
cheerCount: number;
|
||||
favoriteCount: number;
|
||||
visitCount: number;
|
||||
visitorCount: number;
|
||||
hotScore: number;
|
||||
locationReplicationId: string;
|
||||
currentDataBlobName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sceneCount: number;
|
||||
blobCount: number;
|
||||
owner: {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
profileImageName: string | null;
|
||||
};
|
||||
roles: RoomRoleGrant[];
|
||||
}
|
||||
|
||||
export interface IpBan {
|
||||
id: number;
|
||||
cidr: string;
|
||||
reason: string;
|
||||
bannedByAdminId: number;
|
||||
bannedAt: string;
|
||||
until: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
adminPlayerId: number;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: number;
|
||||
reason: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: number;
|
||||
reporterPlayerId: number;
|
||||
targetPlayerId: number;
|
||||
reason: string;
|
||||
context: string;
|
||||
createdAt: string;
|
||||
resolvedAt: string | null;
|
||||
resolverAdminId: number | null;
|
||||
resolutionNote: string | null;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
players: { total: number; onlineNow: number; bannedNow: number };
|
||||
rooms: {
|
||||
total: number;
|
||||
topByVisits: Array<{ id: number; name: string; visitCount: number; visitorCount: number; cheerCount: number }>;
|
||||
};
|
||||
inventions: number;
|
||||
moderation: { openReports: number; activeIpBans: number };
|
||||
recentJoins: Array<{ id: number; username: string; createdAt: string }>;
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
export interface StoreItem {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
imageName: string;
|
||||
currencyType: number;
|
||||
price: number;
|
||||
isActive: boolean;
|
||||
isLimitedTime: boolean;
|
||||
availableUntil: string | null;
|
||||
storefront: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StorefrontDefinition {
|
||||
key: string;
|
||||
storefrontType: number | null;
|
||||
displayName: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
roomInstanceId: number;
|
||||
roomId: number;
|
||||
subRoomId: number;
|
||||
roomName: string;
|
||||
photonRoomId: string;
|
||||
photonRegionId: string;
|
||||
location: string;
|
||||
maxCapacity: number;
|
||||
isPrivate: boolean;
|
||||
participants: Array<{ id: number; username: string; displayName: string }>;
|
||||
}
|
||||
|
||||
export interface PlayerLogEntry {
|
||||
timestamp: string;
|
||||
method: string;
|
||||
host: string;
|
||||
path: string;
|
||||
query: string;
|
||||
status: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
// Mirrors DorkNet.Server.Services.CommunityBoardState (and the
|
||||
// nested DTOs in CommunityBoardService.cs). The 2020 watch decodes
|
||||
// the same shape from /api/communityboard/v1/current — admins
|
||||
// edit it here via /api/admin/v1/communityboard.
|
||||
export interface CommunityBoardState {
|
||||
currentAnnouncement: { message: string; moreInfoUrl: string } | null;
|
||||
featuredPlayer: { id: number; titleOverride: string; urlOverride: string } | null;
|
||||
featuredRoomGroup: { name: string; featuredRooms: number[] } | null;
|
||||
instagramImages: Array<{ imageName: string; imageUrl: string }>;
|
||||
videos: Array<{ blobName: string; title: string; description: string; thumbnailBlobName: string; sourceUrl: string }>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, ApiError, type ApiOptions } from './api';
|
||||
|
||||
interface State<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Read-side hook for "fetch on mount, expose refresh + error state".
|
||||
// Not a replacement for TanStack Query — we don't need its cache /
|
||||
// dedupe / focus-refetch behavior for an admin tool used by 1-2 people.
|
||||
// But every page does the same useState/useEffect/abort dance, so this
|
||||
// collapses it to one line.
|
||||
export function useApi<T = unknown>(path: string, opts: ApiOptions & { manual?: boolean } = {}) {
|
||||
const [state, setState] = useState<State<T>>({ data: null, loading: !opts.manual, error: null });
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const { manual, ...rest } = opts;
|
||||
// Stabilise opts across renders by capturing in a ref the caller can ignore.
|
||||
// Anything that varies between renders (e.g. inline objects) is fine because
|
||||
// we re-run on `path` change only.
|
||||
const optsRef = useRef(rest);
|
||||
optsRef.current = rest;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setState(s => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const data = await api<T>(path, { ...optsRef.current, signal: ctrl.signal });
|
||||
if (!ctrl.signal.aborted) setState({ data, loading: false, error: null });
|
||||
} catch (e) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const msg = e instanceof ApiError ? e.message : (e as Error).message;
|
||||
setState({ data: null, loading: false, error: msg });
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manual) return;
|
||||
refresh();
|
||||
return () => { abortRef.current?.abort(); };
|
||||
}, [refresh, manual]);
|
||||
|
||||
return { ...state, refresh };
|
||||
}
|
||||
Reference in New Issue
Block a user