Files
DorkNet/DorkNet.Server/admin-ui/src/pages/Room.tsx
T
Alexa 0a51c61f66 feat(admin-ui): sub-room caps and test-case issue linking
Two admin screens for work that previously had server support but no way to
reach it.

Rooms → a room → Sub-rooms: per-sub-room max players, with the room's own
advertised capacity shown alongside so the two are not mistaken for each other.
Each row also shows whether the sub-room is a matchmaking target and which blob
it loads.

Activity → Test cases: file a GitHub issue against a case, follow the link, or
unlink it, plus a reconcile button. Filing is idempotent server-side, so the
button is safe to press twice. When GitHub is unconfigured the actions disable
themselves and the page explains why rather than failing on click — a server
with no token is an ordinary deployment.

Both go in as tabs on the existing hub pages rather than new sidebar entries,
matching how the nav is organised.

Server side adds GET api/admin/v1/testcases (the list the SPA renders, with
each case's issue number) and mirrors the three issue routes under
/api/admin/v1/testcases/... because the SPA's client speaks that prefix
exclusively.

I had told the user this UI was not possible on this branch because
wwwroot/admin holds a built bundle. That was wrong — the source is in
DorkNet.Server/admin-ui and builds to it.
2026-07-28 17:21:49 +03:00

1231 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../lib/api';
import type { Player, RoomDetail, RoomInstance, RoomRoleGrant } from '../lib/types';
import { profileImageUrl } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { Empty } from '../components/Empty';
import { Modal } from '../components/Modal';
import { Confirm } from '../components/Confirm';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { useToast } from '../components/Toast';
import { ArrowLeft, RefreshCw, Trash } from '../components/Icons';
// Unified per-room admin view. One screen replaces what used to be
// scattered across Rooms / RR Originals / Instances / individual edit
// modals: general info edit, owner / co-owner / mod / host
// management, live photon instances, recent visitors, danger zone.
type Tab = 'general' | 'roles' | 'subrooms' | 'instances' | 'leaderboards' | 'visitors' | 'danger';
const ROLE_LABEL: Record<0 | 1 | 2, string> = {
0: 'Co-owner',
1: 'Moderator',
2: 'Host',
};
const ACCESSIBILITY: Record<number, string> = {
0: 'Private',
1: 'Public',
2: 'Friends only',
};
export function Room() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const roomId = Number(id);
const { data: room, loading, error, refresh } = useApi<RoomDetail>(`/rooms/${roomId}`);
const [tab, setTab] = useState<Tab>('general');
if (Number.isNaN(roomId) || roomId <= 0) {
return (
<div>
<PageHeader title="Room" />
<Empty title="Invalid room id" />
</div>
);
}
return (
<div>
<PageHeader
title={room?.name ?? `Room #${roomId}`}
blurb={room?.description || undefined}
actions={<>
<Link to="/rooms" className="btn-ghost text-xs"><ArrowLeft /> Back to rooms</Link>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</>}
/>
{error && <div className="card px-4 py-3 text-sm text-danger mb-4">{error}</div>}
{loading && !room && <div className="text-sm text-ink-400">Loading…</div>}
{room && (
<>
<RoomHeaderCard room={room} />
<div className="flex gap-1 mt-4 mb-3 overflow-x-auto border-b border-ink-800">
{(['general', 'roles', 'subrooms', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px ${
tab === t
? 'border-brand-500 text-brand-100'
: 'border-transparent text-ink-300 hover:text-ink-50'
}`}
>
{t === 'general' ? 'General' :
t === 'roles' ? 'Roles & ownership' :
t === 'subrooms' ? 'Sub-rooms' :
t === 'instances' ? 'Live instances' :
t === 'leaderboards' ? 'Leaderboards' :
t === 'visitors' ? 'Recent visitors' :
'Danger zone'}
</button>
))}
</div>
{tab === 'general' && <GeneralTab room={room} onSaved={refresh} />}
{tab === 'roles' && <RolesTab room={room} onChanged={refresh} />}
{tab === 'subrooms' && <SubRoomsTab roomId={room.id} roomCapacity={room.maxCapacity} />}
{tab === 'instances' && <InstancesTab roomId={room.id} />}
{tab === 'leaderboards' && <LeaderboardsTab roomId={room.id} />}
{tab === 'visitors' && <VisitorsTab roomId={room.id} />}
{tab === 'danger' && <DangerTab room={room} onAfter={() => navigate('/rooms')} />}
</>
)}
</div>
);
}
// ── Header card ──────────────────────────────────────────────────────
function RoomHeaderCard({ room }: { room: RoomDetail }) {
const img = profileImageUrl(room.imageName || null, 192);
const tags = room.tagsCsv.split(',').map(s => s.trim()).filter(Boolean);
return (
<div className="card p-4 flex flex-col sm:flex-row gap-4 items-start">
<div className="size-24 rounded-lg overflow-hidden bg-ink-800 flex-shrink-0 flex items-center justify-center">
{img
? <img src={img} alt={room.name} className="size-full object-cover" />
: <div className="text-ink-500 text-xs">no image</div>}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-ink-50">{room.name}</h2>
{room.isDormRoom && <span className="badge-neutral">Dorm</span>}
{!room.isDormRoom && room.isAGRoom && room.owner.id === 1 && <span className="badge-admin">RR Original</span>}
{!room.isDormRoom && room.owner.id !== 1 && <span className="badge-neutral">Custom</span>}
{room.state === 1 && <span className="badge-banned">Archived</span>}
</div>
<div className="text-xs text-ink-400 mt-1">
#{room.id} · {ACCESSIBILITY[room.accessibility] ?? `Access ${room.accessibility}`} · {room.cloningAllowed ? 'Cloning allowed' : 'No cloning'}
</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
<StatPill label="Cheers" value={room.cheerCount} />
<StatPill label="Visits" value={room.visitCount} />
<StatPill label="Visitors" value={room.visitorCount} />
<StatPill label="Scenes" value={room.sceneCount} />
<StatPill label="Blobs" value={room.blobCount} />
<StatPill label="Hot" value={room.hotScore.toFixed(1)} />
</div>
{tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tags.map(t => <span key={t} className="badge-neutral text-[10px]">#{t}</span>)}
</div>
)}
</div>
<div className="text-xs text-ink-400 sm:text-right space-y-1">
<div>
Owner: <Link to={`/players?focus=${room.owner.id}`} className="text-ink-200 hover:text-brand-200">@{room.owner.username || `#${room.owner.id}`}</Link>
</div>
<div>Created {new Date(room.createdAt).toLocaleDateString()}</div>
<div>Updated {new Date(room.updatedAt).toLocaleDateString()}</div>
</div>
</div>
);
}
function StatPill({ label, value }: { label: string; value: number | string }) {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-ink-900 px-2 py-1 ring-1 ring-inset ring-ink-800">
<span className="text-ink-400">{label}</span>
<span className="text-ink-100 font-medium tabular-nums">{value}</span>
</span>
);
}
// ── General tab — name/desc/image/access/cloning/tags/hot ──────────────
function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }) {
const toast = useToast();
const [description, setDescription] = useState(room.description);
const [accessibility, setAccessibility] = useState(room.accessibility);
const [cloningAllowed, setCloningAllowed] = useState(room.cloningAllowed);
const [tagsCsv, setTagsCsv] = useState(room.tagsCsv);
const [hotScore, setHotScore] = useState(String(room.hotScore));
const [imageName, setImageName] = useState(room.imageName);
const [maxCapacity, setMaxCapacity] = useState(String(room.maxCapacity ?? 8));
const [busy, setBusy] = useState(false);
// Re-sync local state if a refresh brings new server values in.
useEffect(() => {
setDescription(room.description);
setAccessibility(room.accessibility);
setCloningAllowed(room.cloningAllowed);
setTagsCsv(room.tagsCsv);
setHotScore(String(room.hotScore));
setImageName(room.imageName);
setMaxCapacity(String(room.maxCapacity ?? 8));
}, [room.id, room.updatedAt]);
const save = async () => {
setBusy(true);
try {
const hot = Number(hotScore);
const cap = parseInt(maxCapacity, 10);
await api(`/rooms/${room.id}/props`, {
method: 'POST',
body: {
Description: description,
Accessibility: accessibility,
CloningAllowed: cloningAllowed,
TagsCsv: tagsCsv,
HotScore: Number.isFinite(hot) ? hot : undefined,
ImageName: imageName,
MaxCapacity: Number.isFinite(cap) ? cap : undefined,
},
});
toast.push('Saved', 'success');
onSaved();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<div className="card p-4 space-y-3 max-w-2xl">
<label className="flex flex-col gap-1">
<span className="label">Description</span>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="input min-h-[80px]"
maxLength={2000}
/>
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="label">Accessibility</span>
<select
value={accessibility}
onChange={e => setAccessibility(Number(e.target.value))}
className="input"
>
<option value={0}>Private</option>
<option value={1}>Public</option>
<option value={2}>Friends only</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="label">Hot score</span>
<input
type="number"
step="0.1"
value={hotScore}
onChange={e => setHotScore(e.target.value)}
className="input tabular-nums"
/>
</label>
<label className="flex flex-col gap-1">
<span className="label">Max players (capacity)</span>
<input
type="number"
min={1}
max={80}
value={maxCapacity}
onChange={e => setMaxCapacity(e.target.value)}
className="input tabular-nums"
/>
<span className="text-[11px] text-ink-500">
Advertised cap on the 2020 client (not hard-enforced yet). 1–80.
</span>
</label>
<label className="flex flex-col gap-1">
<span className="label">Tags (comma-separated)</span>
<input
value={tagsCsv}
onChange={e => setTagsCsv(e.target.value)}
placeholder="featured, recroomoriginal"
className="input"
/>
</label>
<label className="flex flex-col gap-1">
<span className="label">Image blob name</span>
<input
value={imageName}
onChange={e => setImageName(e.target.value)}
placeholder="img_p1_xxxxxxxxxxxx.jpg"
className="input font-mono text-xs"
/>
</label>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={cloningAllowed}
onChange={e => setCloningAllowed(e.target.checked)}
/>
<span>Cloning allowed</span>
</label>
<div className="flex gap-2 pt-2 border-t border-ink-800">
<button onClick={save} disabled={busy} className="btn-primary text-xs">
{busy ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
);
}
// ── Roles tab — owner + co-owners + mods + hosts ────────────────────────
function RolesTab({ room, onChanged }: { room: RoomDetail; onChanged: () => void }) {
const toast = useToast();
const [addingRole, setAddingRole] = useState<0 | 1 | 2 | null>(null);
const [transferOpen, setTransferOpen] = useState(false);
const byRole = useMemo(() => {
const out: Record<0 | 1 | 2, RoomRoleGrant[]> = { 0: [], 1: [], 2: [] };
for (const r of room.roles) out[r.role].push(r);
return out;
}, [room.roles]);
const remove = async (g: RoomRoleGrant) => {
try {
await api(`/rooms/${room.id}/roles/${g.playerId}/${g.role}`, { method: 'DELETE' });
toast.push(`Removed ${ROLE_LABEL[g.role]} grant for @${g.player.username}`, 'success');
onChanged();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
return (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between gap-2">
<div>
<div className="label">Owner</div>
<PlayerRow player={room.owner} />
</div>
<button onClick={() => setTransferOpen(true)} className="btn-secondary text-xs">Transfer ownership</button>
</div>
</div>
{([0, 1, 2] as const).map(role => (
<div key={role} className="card overflow-hidden">
<div className="flex items-center justify-between gap-2 px-4 py-2.5 border-b border-ink-800">
<h3 className="text-sm font-semibold text-ink-50">{ROLE_LABEL[role]}s</h3>
<button onClick={() => setAddingRole(role)} className="btn-secondary text-xs">Add</button>
</div>
{byRole[role].length === 0
? <div className="px-4 py-4 text-xs text-ink-400">No {ROLE_LABEL[role].toLowerCase()} grants.</div>
: <ul className="divide-y divide-ink-800">
{byRole[role].map(g => (
<li key={g.id} className="flex items-center justify-between gap-2 px-4 py-2.5">
<PlayerRow player={g.player} />
<div className="flex items-center gap-2">
{!g.accepted && <span className="badge-neutral">Pending</span>}
<span className="text-xs text-ink-500">{new Date(g.grantedAt).toLocaleDateString()}</span>
<button onClick={() => remove(g)} className="btn-ghost text-xs text-danger"><Trash /> Revoke</button>
</div>
</li>
))}
</ul>}
</div>
))}
{addingRole !== null && (
<AddRoleModal
roomId={room.id}
role={addingRole}
onClose={() => setAddingRole(null)}
onAdded={() => { setAddingRole(null); onChanged(); }}
/>
)}
{transferOpen && (
<TransferOwnerModal
room={room}
onClose={() => setTransferOpen(false)}
onTransferred={() => { setTransferOpen(false); onChanged(); }}
/>
)}
</div>
);
}
function PlayerRow({ player }: { player: { id: number; username: string; displayName: string; profileImageName: string | null } }) {
const img = profileImageUrl(player.profileImageName, 64);
return (
<Link to={`/players?focus=${player.id}`} className="flex items-center gap-2 min-w-0 hover:text-brand-100">
<div className="size-7 rounded-md overflow-hidden bg-ink-800 flex-shrink-0">
{img && <img src={img} alt="" className="size-full object-cover" />}
</div>
<div className="min-w-0">
<div className="text-sm text-ink-50 truncate">@{player.username || `#${player.id}`}</div>
{player.displayName && player.displayName !== player.username &&
<div className="text-xs text-ink-400 truncate">{player.displayName}</div>}
</div>
</Link>
);
}
function AddRoleModal({ roomId, role, onClose, onAdded }: {
roomId: number; role: 0 | 1 | 2; onClose: () => void; onAdded: () => void;
}) {
const { data: players, loading: playersLoading, error: playersError } = useApi<Player[]>('/players?take=500');
const [playerQuery, setPlayerQuery] = useState('');
const [selectedPlayerId, setSelectedPlayerId] = useState('');
const [accepted, setAccepted] = useState(true);
const [busy, setBusy] = useState(false);
const toast = useToast();
const matchingPlayers = useMemo(() => {
const q = playerQuery.trim().toLowerCase();
const source = players ?? [];
if (!q) return source;
return source.filter(p =>
p.id.toString().includes(q) ||
p.username.toLowerCase().includes(q) ||
(p.displayName ?? '').toLowerCase().includes(q)
);
}, [playerQuery, players]);
const selectedPlayer = useMemo(
() => (players ?? []).find(p => p.id === Number(selectedPlayerId)) ?? null,
[players, selectedPlayerId]
);
const playerLabel = (p: Player) => {
const handle = p.username ? `@${p.username}` : `#${p.id}`;
return p.displayName && p.displayName !== p.username
? `${handle} - ${p.displayName} - #${p.id}`
: `${handle} - #${p.id}`;
};
const submit = async () => {
const pid = Number(selectedPlayerId);
if (!Number.isFinite(pid) || pid <= 0) {
toast.push('Select a player', 'error');
return;
}
setBusy(true);
try {
await api(`/rooms/${roomId}/roles`, {
method: 'POST',
body: { PlayerId: pid, Role: role, Accepted: accepted },
});
toast.push(`Granted ${ROLE_LABEL[role]} to ${selectedPlayer ? playerLabel(selectedPlayer) : `#${pid}`}`, 'success');
onAdded();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
open
onClose={onClose}
title={`Add ${ROLE_LABEL[role].toLowerCase()}`}
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button onClick={submit} className="btn-primary text-xs" disabled={busy || !selectedPlayerId}>
{busy ? 'Adding…' : 'Add'}
</button>
</>}
>
<div className="space-y-3 text-sm">
<label className="flex flex-col gap-1">
<span className="label">Search players</span>
<input
value={playerQuery}
onChange={e => setPlayerQuery(e.target.value)}
className="input"
placeholder="Username, display name, or id"
autoFocus
/>
</label>
<label className="flex flex-col gap-1">
<span className="label">Player</span>
<select
value={selectedPlayerId}
onChange={e => setSelectedPlayerId(e.target.value)}
className="input"
disabled={playersLoading}
>
<option value="">{playersLoading ? 'Loading players…' : 'Select a player'}</option>
{matchingPlayers.map(p => (
<option key={p.id} value={p.id}>{playerLabel(p)}</option>
))}
</select>
</label>
{playersError && <div className="text-xs text-danger">{playersError}</div>}
{selectedPlayer && (
<div className="flex items-center gap-2 rounded-md border border-ink-800 bg-ink-900/50 px-2.5 py-2">
<PlayerAvatar
name={selectedPlayer.profileImageName}
displayName={selectedPlayer.displayName || selectedPlayer.username}
size={32}
/>
<div className="min-w-0">
<div className="truncate text-ink-100">{selectedPlayer.username ? `@${selectedPlayer.username}` : `#${selectedPlayer.id}`}</div>
<div className="truncate text-xs text-ink-400">{selectedPlayer.displayName || `Player #${selectedPlayer.id}`}</div>
</div>
</div>
)}
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={accepted} onChange={e => setAccepted(e.target.checked)} />
<span>Mark as accepted (otherwise shows as pending invite)</span>
</label>
</div>
</Modal>
);
}
function TransferOwnerModal({ room, onClose, onTransferred }: {
room: RoomDetail; onClose: () => void; onTransferred: () => void;
}) {
const [pid, setPid] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast();
const ready = pid && confirm === room.name && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
try {
await api(`/rooms/${room.id}/owner`, {
method: 'POST',
body: { NewCreatorPlayerId: Number(pid) },
});
toast.push(`Ownership transferred to #${pid}`, 'success');
onTransferred();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
open
onClose={onClose}
title="Transfer ownership"
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button onClick={submit} className="btn-danger text-xs" disabled={!ready}>
{busy ? 'Transferring…' : 'Transfer'}
</button>
</>}
>
<div className="space-y-3 text-sm">
<p>Reassign the room's creator. The previous owner (@{room.owner.username}) keeps a Co-owner grant automatically so they don't lose access.</p>
<label className="flex flex-col gap-1">
<span className="label">New owner player id</span>
<input value={pid} onChange={e => setPid(e.target.value)} className="input font-mono" placeholder="1362428" />
</label>
<label className="flex flex-col gap-1">
<span className="label">Type the room name to confirm</span>
<input value={confirm} onChange={e => setConfirm(e.target.value)} className="input font-mono" placeholder={room.name} />
</label>
</div>
</Modal>
);
}
// ── Instances tab — live photon matches in this room ────────────────────
interface SubRoom {
id: string;
subRoomId: number;
name: string;
maxPlayers: number;
canMatchmakeInto: boolean;
isSandbox: boolean;
dataBlobName: string;
}
// Per-sub-room player caps. Distinct from the room's own MaxCapacity, which is
// what the ROOM advertises and what matchmaking hands to
// RoomInstance.MaxCapacity — each sub-room caps itself separately, and the two
// are easy to conflate. The same value is editable in game by the room owner
// ("Max Player Count in This Subroom"); this reaches rooms an admin does not
// own.
function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: number }) {
const toast = useToast();
const { data, loading, error, refresh } = useApi<SubRoom[]>(`/rooms/${roomId}/subrooms`);
const [edits, setEdits] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<number | null>(null);
const save = async (sub: SubRoom) => {
const raw = edits[sub.subRoomId];
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 80) {
toast.push('Max players must be a whole number between 1 and 80', 'error');
return;
}
setSaving(sub.subRoomId);
try {
await api(`/rooms/${roomId}/subrooms/${sub.subRoomId}/maxplayers`, {
method: 'PUT',
body: { MaxPlayers: value },
});
toast.push(`${sub.name || `Sub-room ${sub.subRoomId}`} capped at ${value}`, 'success');
setEdits(prev => {
const next = { ...prev };
delete next[sub.subRoomId];
return next;
});
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setSaving(null);
}
};
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-ink-800">
<div className="text-xs text-ink-400">
Room advertises <span className="text-ink-200 font-medium">{roomCapacity}</span> — each
sub-room caps itself separately
</div>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
{data && data.length === 0 && <Empty title="No sub-rooms" />}
{data && data.length > 0 && (
<ul className="divide-y divide-ink-800">
{data.map(sub => {
const pending = edits[sub.subRoomId];
const dirty = pending !== undefined && Number(pending) !== sub.maxPlayers;
return (
<li key={sub.id} className="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink-50">{sub.name || '(unnamed)'}</span>
<span className="badge-neutral">SubRoom {sub.subRoomId}</span>
{sub.isSandbox && <span className="badge-neutral">Sandbox</span>}
{!sub.canMatchmakeInto && <span className="badge-banned">No matchmaking</span>}
</div>
{sub.dataBlobName && (
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">{sub.dataBlobName}</div>
)}
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-ink-400">Max players</label>
<input
type="number"
min={1}
max={80}
value={pending ?? String(sub.maxPlayers)}
onChange={e => setEdits(prev => ({ ...prev, [sub.subRoomId]: e.target.value }))}
className="input w-20 text-sm"
/>
<button
onClick={() => save(sub)}
disabled={!dirty || saving === sub.subRoomId}
className="btn-primary text-xs"
>
{saving === sub.subRoomId ? 'Saving…' : 'Save'}
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
function InstancesTab({ roomId }: { roomId: number }) {
const toast = useToast();
const { data, loading, error, refresh } = useApi<RoomInstance[]>(`/rooms/${roomId}/instances`);
const [pullInto, setPullInto] = useState<RoomInstance | null>(null);
const [closing, setClosing] = useState<RoomInstance | null>(null);
const close = async (inst: RoomInstance) => {
try {
const res = await api<{ kicked: number[] }>(
`/rooms/${roomId}/instances/${inst.roomInstanceId}/close`,
{ method: 'POST', body: { Reason: 'Instance closed by admin' } },
);
toast.push(`Closed instance — kicked ${res.kicked.length}`, 'success');
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-ink-800">
<div className="text-xs text-ink-400">{data ? `${data.length} active` : ''}</div>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
{data && data.length === 0 && <Empty title="No active instances" />}
{data && data.length > 0 && (
<ul className="divide-y divide-ink-800">
{data.map(inst => (
<li key={inst.roomInstanceId} className="px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<div className="flex flex-wrap items-center gap-2 min-w-0">
<span className="text-sm font-medium text-ink-50">Instance #{inst.roomInstanceId}</span>
<span className="badge-neutral">SubRoom {inst.subRoomId}</span>
{inst.isPrivate && <span className="badge-banned">Private</span>}
<span className="text-xs text-ink-500 font-mono truncate">{inst.photonRegionId}/{inst.photonRoomId}</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setPullInto(inst)} className="btn-secondary text-xs">Pull player in</button>
<button onClick={() => setClosing(inst)} className="btn-ghost text-xs text-danger">
<Trash /> Close
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{inst.participants.map(p => (
<Link
key={p.id}
to={`/players?focus=${p.id}`}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ring-1 ring-inset ${
p.isMaster
? 'bg-brand-500/15 text-brand-100 ring-brand-500/30'
: 'bg-ink-900 text-ink-200 ring-ink-800 hover:bg-ink-800'
}`}
>
{p.isMaster && <span className="text-[10px] uppercase font-semibold tracking-wider text-brand-300">Master</span>}
<span>@{p.username}</span>
</Link>
))}
{inst.participants.length === 0 && <span className="text-xs text-ink-500">empty</span>}
</div>
</li>
))}
</ul>
)}
{pullInto && (
<PullPlayerModal
roomId={roomId}
instance={pullInto}
onClose={() => setPullInto(null)}
onPulled={() => { setPullInto(null); refresh(); }}
/>
)}
<Confirm
open={closing !== null}
onClose={() => setClosing(null)}
title="Close instance"
body={<>Kick all {closing?.participants.length ?? 0} player(s) out of instance <span className="font-mono text-ink-100">#{closing?.roomInstanceId}</span>? The Photon room dies once it's empty.</>}
confirmLabel="Close instance"
destructive
onConfirm={() => { if (closing) close(closing); }}
/>
</div>
);
}
function PullPlayerModal({ roomId, instance, onClose, onPulled }: {
roomId: number; instance: RoomInstance; onClose: () => void; onPulled: () => void;
}) {
const [pidStr, setPidStr] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast();
const submit = async () => {
const pid = Number(pidStr);
if (!Number.isFinite(pid) || pid <= 0) { toast.push('Enter a numeric player id', 'error'); return; }
setBusy(true);
try {
await api(`/rooms/${roomId}/instances/${instance.roomInstanceId}/pull`, {
method: 'POST',
body: { PlayerId: pid },
});
toast.push(`Pulled #${pid} into instance`, 'success');
onPulled();
} catch (e) { toast.push((e as Error).message, 'error'); }
finally { setBusy(false); }
};
return (
<Modal
open
onClose={onClose}
title={`Pull a player into instance #${instance.roomInstanceId}`}
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button onClick={submit} className="btn-primary text-xs" disabled={busy}>{busy ? 'Pulling…' : 'Pull in'}</button>
</>}
>
<div className="space-y-3 text-sm">
<p className="text-xs text-ink-400">
Re-points the target player's presence at this exact Photon shard ({instance.photonRegionId}/{instance.photonRoomId})
and pushes a SubscriptionUpdateGameSession so their watch joins the same instance the existing participants are in.
</p>
<label className="flex flex-col gap-1">
<span className="label">Player id</span>
<input value={pidStr} onChange={e => setPidStr(e.target.value)} className="input font-mono" placeholder="1362428" autoFocus />
</label>
</div>
</Modal>
);
}
// ── Leaderboards tab — channels mapped to this room + orphan registry ──
interface RoomLeaderboardRow {
channel: number;
name: string;
lowerIsBetter: boolean;
valueFormat: string;
entryCount: number;
bestValue: number | null;
}
interface OrphanRow {
roomId: number;
channel: number;
entryCount: number;
lastSeen: string;
}
interface ChannelEntries {
roomId: number;
channel: number;
name: string;
lowerIsBetter: boolean;
valueFormat: string;
entries: Array<{
rank: number;
id: number;
playerId: number;
username: string | null;
displayName: string | null;
value: number;
updatedAt: string;
}>;
}
function formatChannelValue(v: number | null | undefined, fmt: string) {
if (v === null || v === undefined) return '—';
if (fmt === 'time-ms') {
const total = v;
const m = Math.floor(total / 60000);
const s = Math.floor((total % 60000) / 1000);
const ms = total % 1000;
return `${m}:${s.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
}
return v.toLocaleString();
}
function LeaderboardsTab({ roomId }: { roomId: number }) {
const toast = useToast();
const lb = useApi<RoomLeaderboardRow[]>(`/rooms/${roomId}/leaderboards`);
const orphans = useApi<OrphanRow[]>(`/leaderboards/orphans?roomId=${roomId}`);
const [addOpen, setAddOpen] = useState(false);
const [adoptChannel, setAdoptChannel] = useState<number | null>(null);
const [expanded, setExpanded] = useState<number | null>(null);
const refresh = () => { lb.refresh(); orphans.refresh(); };
return (
<div className="space-y-4">
<div className="card overflow-hidden">
<div className="flex items-center justify-between gap-2 px-4 py-2.5 border-b border-ink-800">
<h3 className="text-sm font-semibold text-ink-50">Channels mapped to this room</h3>
<button onClick={() => setAddOpen(true)} className="btn-secondary text-xs">Register channel</button>
</div>
{lb.error && <div className="px-4 py-3 text-sm text-danger">{lb.error}</div>}
{lb.data && lb.data.length === 0 && (
<Empty title="No leaderboards yet — register a channel id, or adopt one from the orphan list below" />
)}
{lb.data && lb.data.length > 0 && (
<ul className="divide-y divide-ink-800">
{lb.data.map(c => (
<li key={c.channel}>
<div className="flex items-center justify-between gap-2 px-4 py-2.5">
<button
onClick={() => setExpanded(expanded === c.channel ? null : c.channel)}
className="text-left min-w-0 flex-1 hover:text-brand-100"
>
<div className="text-sm text-ink-50 truncate">{c.name}</div>
<div className="text-xs text-ink-400 tabular-nums">
Channel #{c.channel} · {c.entryCount} {c.entryCount === 1 ? 'entry' : 'entries'} · {c.lowerIsBetter ? 'lower is better' : 'higher is better'}
</div>
</button>
<div className="flex items-center gap-3">
<div className="text-right">
<div className="text-[10px] uppercase text-ink-500">Best</div>
<div className="text-sm tabular-nums text-ink-100">{formatChannelValue(c.bestValue, c.valueFormat)}</div>
</div>
<button onClick={() => setExpanded(expanded === c.channel ? null : c.channel)} className="btn-ghost text-xs">
{expanded === c.channel ? 'Hide' : 'View'}
</button>
<button
onClick={async () => {
if (!confirm(`Unregister channel #${c.channel}? Scores stay, but it stops appearing under this room.`)) return;
try {
await api(`/leaderboards/meta/${c.channel}?roomId=${roomId}`, { method: 'DELETE' });
toast.push('Unregistered', 'success');
refresh();
} catch (e) { toast.push((e as Error).message, 'error'); }
}}
className="btn-ghost text-xs text-danger"
>
<Trash />
</button>
</div>
</div>
{expanded === c.channel && (
<ChannelEntriesPanel roomId={roomId} channel={c.channel} />
)}
</li>
))}
</ul>
)}
</div>
<div className="card overflow-hidden">
<div className="flex items-center justify-between gap-2 px-4 py-2.5 border-b border-ink-800">
<h3 className="text-sm font-semibold text-ink-50">Unmapped channels with score data</h3>
<div className="text-xs text-ink-400">
{orphans.data ? `${orphans.data.length} orphan${orphans.data.length === 1 ? '' : 's'}` : ''}
</div>
</div>
<div className="px-4 py-2.5 text-xs text-ink-400 border-b border-ink-800">
Play a stat-reporting game mode (Stunt Runner course, Paintball, etc.) and its channel id will appear here.
Adopt the channel to attach it to this room and give it a name.
</div>
{orphans.data && orphans.data.length === 0 && <Empty title="No orphan channels — every channel with score rows is mapped" />}
{orphans.data && orphans.data.length > 0 && (
<ul className="divide-y divide-ink-800">
{orphans.data.map(o => (
<li key={o.channel} className="flex items-center justify-between gap-2 px-4 py-2.5">
<div>
<div className="text-sm text-ink-50">Channel #{o.channel}</div>
<div className="text-xs text-ink-400 tabular-nums">{o.entryCount} entries · last update {new Date(o.lastSeen).toLocaleString()}</div>
</div>
<button onClick={() => setAdoptChannel(o.channel)} className="btn-secondary text-xs">Adopt for this room</button>
</li>
))}
</ul>
)}
</div>
{(addOpen || adoptChannel !== null) && (
<RegisterChannelModal
roomId={roomId}
initialChannel={adoptChannel ?? undefined}
onClose={() => { setAddOpen(false); setAdoptChannel(null); }}
onSaved={() => { setAddOpen(false); setAdoptChannel(null); refresh(); }}
/>
)}
</div>
);
}
function ChannelEntriesPanel({ roomId, channel }: { roomId: number; channel: number }) {
const { data, loading, error } = useApi<ChannelEntries>(`/leaderboards/${channel}?roomId=${roomId}&take=25`);
if (loading && !data) return <div className="px-6 py-3 text-xs text-ink-400 bg-ink-900/40">Loading…</div>;
if (error) return <div className="px-6 py-3 text-xs text-danger bg-ink-900/40">{error}</div>;
if (!data) return null;
if (data.entries.length === 0) return <div className="px-6 py-3 text-xs text-ink-400 bg-ink-900/40">No entries yet.</div>;
return (
<div className="bg-ink-900/40 border-t border-ink-800">
<table className="w-full text-xs">
<thead className="text-[10px] uppercase tracking-wider text-ink-400">
<tr>
<th className="text-left font-medium px-4 py-1.5 w-10">#</th>
<th className="text-left font-medium px-4 py-1.5">Player</th>
<th className="text-right font-medium px-4 py-1.5">Value</th>
<th className="text-right font-medium px-4 py-1.5">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800/60">
{data.entries.map(e => (
<tr key={e.id}>
<td className="px-4 py-1.5 text-ink-300 tabular-nums">{e.rank}</td>
<td className="px-4 py-1.5">
<Link to={`/players?focus=${e.playerId}`} className="text-ink-100 hover:text-brand-200">@{e.username ?? `#${e.playerId}`}</Link>
</td>
<td className="px-4 py-1.5 text-right tabular-nums text-ink-100">{formatChannelValue(e.value, data.valueFormat)}</td>
<td className="px-4 py-1.5 text-right text-ink-500">{new Date(e.updatedAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function RegisterChannelModal({ roomId, initialChannel, onClose, onSaved }: {
roomId: number; initialChannel?: number; onClose: () => void; onSaved: () => void;
}) {
const toast = useToast();
const [channel, setChannel] = useState(initialChannel?.toString() ?? '');
const [name, setName] = useState('');
const [lowerIsBetter, setLowerIsBetter] = useState(false);
const [valueFormat, setValueFormat] = useState<'count' | 'time-ms' | 'score'>('count');
const [busy, setBusy] = useState(false);
const submit = async () => {
const ch = Number(channel);
if (!Number.isInteger(ch) || ch <= 0) { toast.push('Channel must be a positive integer', 'error'); return; }
if (!name.trim()) { toast.push('Name required', 'error'); return; }
setBusy(true);
try {
await api(`/leaderboards/meta`, {
method: 'POST',
body: { Channel: ch, RoomId: roomId, Name: name.trim(), LowerIsBetter: lowerIsBetter, ValueFormat: valueFormat },
});
toast.push(`Channel #${ch} mapped to this room`, 'success');
onSaved();
} catch (e) { toast.push((e as Error).message, 'error'); }
finally { setBusy(false); }
};
return (
<Modal
open
onClose={onClose}
title={initialChannel !== undefined ? `Adopt channel #${initialChannel}` : 'Register leaderboard channel'}
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button onClick={submit} className="btn-primary text-xs" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
</>}
>
<div className="space-y-3 text-sm">
<label className="flex flex-col gap-1">
<span className="label">Channel id</span>
<input
value={channel}
onChange={e => setChannel(e.target.value)}
className="input font-mono"
placeholder="7"
disabled={initialChannel !== undefined}
autoFocus={initialChannel === undefined}
/>
</label>
<label className="flex flex-col gap-1">
<span className="label">Display name</span>
<input value={name} onChange={e => setName(e.target.value)} className="input" placeholder="Stunt Runner — Forest Loop" autoFocus={initialChannel !== undefined} />
</label>
<label className="flex flex-col gap-1">
<span className="label">Value format</span>
<select value={valueFormat} onChange={e => setValueFormat(e.target.value as 'count' | 'time-ms' | 'score')} className="input">
<option value="count">Count (e.g. wins)</option>
<option value="time-ms">Time in milliseconds</option>
<option value="score">Score (points)</option>
</select>
</label>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={lowerIsBetter} onChange={e => setLowerIsBetter(e.target.checked)} />
<span>Lower value wins (race times, lap times, etc.)</span>
</label>
</div>
</Modal>
);
}
// ── Visitors tab — recent unique visitors w/ visit counts ───────────────
interface VisitorRow {
playerId: number;
username: string;
displayName: string;
visitCount: number;
firstVisitAt: string;
lastVisitAt: string;
}
function VisitorsTab({ roomId }: { roomId: number }) {
const { data, loading, error, refresh } = useApi<VisitorRow[]>(`/rooms/${roomId}/visitors`);
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-ink-800">
<div className="text-xs text-ink-400">{data ? `${data.length} recent visitors` : ''}</div>
<button onClick={refresh} className="btn-ghost text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
{data && data.length === 0 && <Empty title="No visitors yet" />}
{data && data.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[520px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
<tr>
<th className="text-left font-medium px-4 py-2.5">Player</th>
<th className="text-right font-medium px-4 py-2.5">Visits</th>
<th className="text-right font-medium px-4 py-2.5">First</th>
<th className="text-right font-medium px-4 py-2.5">Last</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{data.map(v => (
<tr key={v.playerId} className="table-row-hover">
<td className="px-4 py-2 text-ink-50">
<Link to={`/players?focus=${v.playerId}`} className="hover:text-brand-200">@{v.username}</Link>
</td>
<td className="px-4 py-2 text-right tabular-nums">{v.visitCount}</td>
<td className="px-4 py-2 text-right text-xs text-ink-400">{new Date(v.firstVisitAt).toLocaleDateString()}</td>
<td className="px-4 py-2 text-right text-xs text-ink-400">{new Date(v.lastVisitAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
);
}
// ── Danger zone — archive / purge ────────────────────────────────────────
function DangerTab({ room, onAfter }: { room: RoomDetail; onAfter: () => void }) {
const toast = useToast();
const [archiveOpen, setArchiveOpen] = useState(false);
const [purgeOpen, setPurgeOpen] = useState(false);
const [confirm1, setConfirm1] = useState('');
const [confirm2, setConfirm2] = useState('');
const [busy, setBusy] = useState(false);
// Anything not owned by the system seed account (id 1) can be purged —
// including dorms. Purging a dorm wipes its contents but the player's dorm
// regenerates on next join; the two typed-name confirmations are the guard.
const isCustom = room.owner.id !== 1;
const isArchived = room.state === 1;
const purgeReady = confirm1 === room.name && confirm2 === room.name && !busy;
const archive = async () => {
try {
await api(`/rooms/${room.id}`, { method: 'DELETE', body: { Reason: 'admin' } });
toast.push(`Archived ${room.name}`, 'success');
onAfter();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const restore = async () => {
try {
await api(`/rooms/${room.id}/restore`, { method: 'POST' });
toast.push(`Restored ${room.name}`, 'success');
onAfter();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const purge = async () => {
if (!purgeReady) return;
setBusy(true);
try {
const res = await api<{ deleted: number; blobs: number; scenes: number }>(
`/rooms/${room.id}/purge`,
{ method: 'POST', body: { Reason: 'admin', ConfirmName1: confirm1, ConfirmName2: confirm2 } },
);
toast.push(`Purged ${room.name} (${res.blobs} blobs · ${res.scenes} scenes)`, 'success');
onAfter();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<div className="space-y-3 max-w-2xl">
<div className="card p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-ink-50">{isArchived ? 'Restore' : 'Archive'}</h3>
<p className="text-xs text-ink-400 mt-1">
{isArchived
? 'This room is archived (State=1). Restore sets State=0 (Active) so it shows up in browse/search again.'
: 'Soft delete — sets State=Archived. The room stops appearing in browse/search but stays restorable.'}
</p>
</div>
{isArchived ? (
<button onClick={restore} className="btn-secondary text-xs" disabled={busy}>
Restore
</button>
) : (
<button onClick={() => setArchiveOpen(true)} className="btn-secondary text-xs text-danger">
<Trash /> Archive
</button>
)}
</div>
</div>
{isCustom && (
<div className="card p-4 border-danger/40">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-danger">Purge permanently</h3>
<p className="text-xs text-ink-400 mt-1">
Hard delete — drops the row, every scene, every per-room
<code className="font-mono mx-1">.room</code> blob, and the thumbnail.
The name frees up so a re-import works. Irreversible.
</p>
<div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger mt-3">
Type the room name <span className="font-mono text-ink-100">{room.name}</span> in BOTH inputs. Case-sensitive.
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-3">
<input value={confirm1} onChange={e => setConfirm1(e.target.value)} className="input font-mono" placeholder="confirm (1 of 2)" />
<input value={confirm2} onChange={e => setConfirm2(e.target.value)} className="input font-mono" placeholder="confirm (2 of 2)" />
</div>
</div>
<button onClick={() => setPurgeOpen(true)} disabled={!purgeReady} className="btn-danger text-xs">
{busy ? 'Purging…' : 'Purge permanently'}
</button>
</div>
</div>
)}
<Confirm
open={archiveOpen}
onClose={() => setArchiveOpen(false)}
title="Archive room"
body={<>Set <span className="font-medium text-ink-50">{room.name}</span> to State=Archived?</>}
confirmLabel="Archive"
destructive
onConfirm={archive}
/>
<Confirm
open={purgeOpen}
onClose={() => setPurgeOpen(false)}
title="Purge room permanently"
body={<>This is irreversible. Continue?</>}
confirmLabel="Yes, purge"
destructive
onConfirm={purge}
/>
</div>
);
}