mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
6605f61047
The Maker Pen permission check runs a room precondition that looks the
current room up in AGRoomRuntimeConfig — a table baked into the client's
resources.assets — and returns a per-original boolean. Rec Center and
Stunt Runner allow creation there; Rec Royale does not, so no blob, role or
server response can grant the pen while the client treats the room as
that original. The table is consulted only for rooms with IsRRO (our
IsAGRoom) set.
Expose IsAGRoom on rooms/{id}/props (+ a checkbox on the General tab) so a
CLONE of Rec Royale can be flipped to a non-original with a real save
attached; whether the client still loads the baked Frontier scene for a
non-original is what that experiment answers. docs/admin.md explains the
gate and the two possible outcomes.
1368 lines
57 KiB
TypeScript
1368 lines
57 KiB
TypeScript
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 [isAGRoom, setIsAGRoom] = useState(room.isAGRoom);
|
||
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);
|
||
setIsAGRoom(room.isAGRoom);
|
||
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,
|
||
IsAGRoom: isAGRoom,
|
||
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>
|
||
<label className="flex items-center gap-2 text-sm" title="What the client reads as IsRRO. On: load the baked scene from the client's assets and consult the client's built-in per-original table (which forbids creation in Rec Royale). Off: treat as a custom room — roles and blob decide. Flip a CLONE of an original off to test editable Rec Royale.">
|
||
<input
|
||
type="checkbox"
|
||
checked={isAGRoom}
|
||
onChange={e => setIsAGRoom(e.target.checked)}
|
||
/>
|
||
<span>Rec Room Original (IsRRO) — baked scene + client creation table</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;
|
||
/** What the client is actually told to download: scene blob, else room
|
||
* blob, else synthetic default — or '' for a baked Rec Room Original
|
||
* with no blob enabled (scene geometry only, no Maker Pen / chips). */
|
||
effectiveBlobName: string;
|
||
isBakedOriginal: boolean;
|
||
/** No RoomScenes row exists yet (seeded baked originals); the entry
|
||
* scene is synthesised on read and created by "Enable Maker Pen blob". */
|
||
isVirtual?: boolean;
|
||
}
|
||
|
||
// 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 [blobBusy, setBlobBusy] = useState<number | null>(null);
|
||
const [resetArmed, setResetArmed] = useState<number | null>(null);
|
||
|
||
// Baked Rec Room Originals ship with no data blob, so the client never
|
||
// runs its room-data path there (no Maker Pen, no CV2 chips, no saves).
|
||
// "Enable" stamps the synthetic default name the CDN already answers with
|
||
// the RRO-editable blob; "Reset" clears the pointers back to scene-only.
|
||
// Attach a real PersistedRoomData save (any client version) to a sub-room:
|
||
// the server rewrites it for this room (activity id, 2023 version clamp,
|
||
// drops output log / sub-room id / unknown fields; "basics" keeps only the
|
||
// game-role + game-rule objects and strips circuits).
|
||
const [uploadMode, setUploadMode] = useState<'full' | 'basics'>('basics');
|
||
const uploadBlob = async (sub: SubRoom, file: File) => {
|
||
setBlobBusy(sub.subRoomId);
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', file, file.name);
|
||
const res = await api<{ dataBlobName: string; summary: { viewsIn: number; viewsKept: number; versionIn: number; versionOut: number } }>(
|
||
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/upload?mode=${uploadMode}`,
|
||
{ method: 'POST', formData: fd, timeoutMs: 120000 },
|
||
);
|
||
toast.push(
|
||
`Uploaded ${res.dataBlobName}: ${res.summary.viewsKept}/${res.summary.viewsIn} objects kept, version ${res.summary.versionIn} → ${res.summary.versionOut}`,
|
||
'success',
|
||
);
|
||
refresh();
|
||
} catch (e) {
|
||
toast.push((e as Error).message, 'error');
|
||
} finally {
|
||
setBlobBusy(null);
|
||
}
|
||
};
|
||
|
||
const setBlob = async (sub: SubRoom, action: 'enable' | 'reset') => {
|
||
setBlobBusy(sub.subRoomId);
|
||
try {
|
||
const res = await api<{ effectiveBlobName: string; changed: boolean }>(
|
||
`/rooms/${roomId}/subrooms/${sub.subRoomId}/blob/${action}`,
|
||
{ method: 'POST' },
|
||
);
|
||
toast.push(
|
||
action === 'enable'
|
||
? `${sub.name || `Sub-room ${sub.subRoomId}`} now loads ${res.effectiveBlobName}`
|
||
: `${sub.name || `Sub-room ${sub.subRoomId}`} reset to the baked scene (no blob)`,
|
||
'success',
|
||
);
|
||
setResetArmed(null);
|
||
refresh();
|
||
} catch (e) {
|
||
toast.push((e as Error).message, 'error');
|
||
} finally {
|
||
setBlobBusy(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>
|
||
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">
|
||
{sub.effectiveBlobName
|
||
? sub.effectiveBlobName
|
||
: sub.isBakedOriginal
|
||
? 'no data blob — baked scene only (no Maker Pen / chips)'
|
||
: '(synthetic default blob)'}
|
||
</div>
|
||
{sub.isBakedOriginal && (
|
||
<div className="flex flex-wrap items-center gap-2 mt-1.5">
|
||
<label className="text-xs text-ink-400">Upload save</label>
|
||
<select
|
||
value={uploadMode}
|
||
onChange={e => setUploadMode(e.target.value as 'full' | 'basics')}
|
||
className="input text-xs py-0.5 w-40"
|
||
title="basics = keep only the game-role and game-rule objects and strip circuits (a newer CircuitsV2 graph is rejected by the 2023 client); full = keep every object"
|
||
>
|
||
<option value="basics">basics (roles + game rule)</option>
|
||
<option value="full">full (every object)</option>
|
||
</select>
|
||
<input
|
||
type="file"
|
||
accept=".binpb,.room,.dat,.bin"
|
||
disabled={blobBusy === sub.subRoomId}
|
||
onChange={e => {
|
||
const f = e.target.files?.[0];
|
||
if (f) uploadBlob(sub, f);
|
||
e.target.value = '';
|
||
}}
|
||
className="text-xs"
|
||
/>
|
||
</div>
|
||
)}
|
||
{sub.isBakedOriginal && (
|
||
<div className="flex items-center gap-2 mt-1.5">
|
||
{!sub.dataBlobName ? (
|
||
<button
|
||
onClick={() => setBlob(sub, 'enable')}
|
||
disabled={blobBusy === sub.subRoomId}
|
||
className="btn-secondary text-xs"
|
||
title="Serve a room data blob for this baked sub-room so Maker Pen, CV2 chips and saves work. The first in-game save replaces it with a real upload."
|
||
>
|
||
{blobBusy === sub.subRoomId ? 'Enabling…' : 'Enable Maker Pen blob'}
|
||
</button>
|
||
) : resetArmed === sub.subRoomId ? (
|
||
<>
|
||
<span className="text-xs text-danger">Drops the blob pointer; saved blobs stay restorable.</span>
|
||
<button
|
||
onClick={() => setBlob(sub, 'reset')}
|
||
disabled={blobBusy === sub.subRoomId}
|
||
className="btn-danger text-xs"
|
||
>
|
||
{blobBusy === sub.subRoomId ? 'Resetting…' : 'Confirm reset'}
|
||
</button>
|
||
<button onClick={() => setResetArmed(null)} className="btn-secondary text-xs">Cancel</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
onClick={() => setResetArmed(sub.subRoomId)}
|
||
className="btn-secondary text-xs"
|
||
title="Go back to the baked scene with no data blob (escape hatch if a save broke the room)."
|
||
>
|
||
Reset to baked scene
|
||
</button>
|
||
)}
|
||
</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 || sub.isVirtual}
|
||
className="btn-primary text-xs"
|
||
title={sub.isVirtual ? 'This room has no sub-room row yet — enable the blob first' : undefined}
|
||
>
|
||
{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>
|
||
);
|
||
}
|