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

Targets Rec Room 2020.12.18 client.

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

Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
Alexa
2026-05-27 01:04:12 +03:00
commit 4edf25b2d5
375 changed files with 201291 additions and 0 deletions
+323
View File
@@ -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;
}
}
+58
View File
@@ -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);
}
+59
View File
@@ -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}`;
}
}
+241
View File
@@ -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 }>;
}
+47
View File
@@ -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 };
}