Files
DorkNet/DorkNet.Server/admin-ui/src/pages/Room.tsx
T
Alexa d471b418b9 Import march-2020-03-10 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).

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 dorknet-clientpatch-{key}.zip
  for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
  dorknet-server-march_2020_03_10-win-x64.zip and
  dorknet-clientpatch-march_2020_03_10.zip as Release assets.

Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
2026-05-27 01:06:12 +03:00

1026 lines
42 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../lib/api';
import type { 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 { 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' | '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', '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 === '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 === '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 [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);
}, [room.id, room.updatedAt]);
const save = async () => {
setBusy(true);
try {
const hot = Number(hotScore);
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,
},
});
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">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 [playerIdStr, setPlayerIdStr] = useState('');
const [accepted, setAccepted] = useState(true);
const [busy, setBusy] = useState(false);
const toast = useToast();
const submit = async () => {
const pid = Number(playerIdStr);
if (!Number.isFinite(pid) || pid <= 0) {
toast.push('Enter a numeric player id', '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 #${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}>
{busy ? 'Adding…' : 'Add'}
</button>
</>}
>
<div className="space-y-3 text-sm">
<label className="flex flex-col gap-1">
<span className="label">Player id</span>
<input
value={playerIdStr}
onChange={e => setPlayerIdStr(e.target.value)}
className="input font-mono"
placeholder="1362428"
autoFocus
/>
</label>
<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 ────────────────────
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 {
channel: number;
entryCount: number;
lastSeen: string;
}
interface ChannelEntries {
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`);
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}`, { 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 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({ channel }: { channel: number }) {
const { data, loading, error } = useApi<ChannelEntries>(`/leaderboards/${channel}?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);
const isCustom = !room.isDormRoom && room.owner.id !== 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 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">Archive</h3>
<p className="text-xs text-ink-400 mt-1">
Soft delete — sets State=Archived. The room stops appearing in browse/search but stays restorable.
</p>
</div>
<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>
);
}