import { useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router'; import { api, get } from '../lib/api'; import type { Player, PlayerDetail } from '../lib/types'; import { absoluteTime, currencyName, num, relativeTime } from '../lib/format'; import { PageHeader } from '../components/PageHeader'; import { Modal } from '../components/Modal'; import { Confirm } from '../components/Confirm'; import { useToast } from '../components/Toast'; import { Empty } from '../components/Empty'; import { PlayerAvatar } from '../components/PlayerAvatar'; import { RefreshCw, Search } from '../components/Icons'; import { GiftPanel } from './Gift'; import { PasswordResetCard } from './Passwords'; export function Players({ embedded }: { embedded?: boolean } = {}) { const [params] = useSearchParams(); const [query, setQuery] = useState(params.get('query') ?? ''); const [rows, setRows] = useState(null); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); const [selectedId, setSelectedId] = useState(null); // Debounced search — the watch admin sat on /api/admin/v1/players for // every keypress, blowing through the SQLite write lock pool. 200ms // is enough to feel responsive without thrashing the DB. const search = useMemo(() => { const handler = (q: string) => { setLoading(true); setErr(null); get(`/players?take=200${q ? `&query=${encodeURIComponent(q)}` : ''}`) .then(setRows) .catch(e => setErr((e as Error).message)) .finally(() => setLoading(false)); }; let t: number | undefined; return (q: string) => { window.clearTimeout(t); t = window.setTimeout(() => handler(q), 200); }; }, []); useEffect(() => { search(query); }, [query, search]); useEffect(() => { const next = params.get('query') ?? ''; if (next !== query) setQuery(next); }, [params, query]); const refreshBtn = ( ); return (
{embedded ? (
{refreshBtn}
) : ( )}
setQuery(e.target.value)} placeholder="Search username or display name…" className="input pl-8" />
{rows ? `${rows.length} match${rows.length === 1 ? '' : 'es'}` : ''}
{err &&
{err}
}
{rows?.map(p => ( setSelectedId(p.id)} > ))} {rows && rows.length === 0 && ( )} {!rows && !err && ( )}
Account Status Level Last seen Joined
{p.displayName || p.username}
@{p.username} · #{p.id}
{p.online && Online} {p.bannedUntil && new Date(p.bannedUntil) > new Date() && Banned} {p.isAdmin && Admin} {p.isDeveloper && Dev} {p.isCommunityTeam && Community} {p.isJunior && Junior} {p.isVerified && Verified} {!p.online && !p.bannedUntil && !p.isAdmin && !p.isDeveloper && !p.isCommunityTeam && !p.isJunior && !p.isVerified && ( — )}
{p.level} {relativeTime(p.lastSeenAt)} {relativeTime(p.createdAt)}
Loading players…
{selectedId !== null && ( setSelectedId(null)} onChanged={() => { setSelectedId(null); search(query); }} /> )}
); } // ── Detail / edit modal ─────────────────────────────────────────────── function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => void; onChanged: () => void }) { const [data, setData] = useState(null); const [err, setErr] = useState(null); const [tab, setTab] = useState<'overview' | 'mod' | 'grants' | 'gift' | 'profile'>('overview'); const reload = () => { setErr(null); get(`/players/${id}`) .then(setData) .catch(e => setErr((e as Error).message)); }; useEffect(reload, [id]); return ( {err &&
{err}
} {!data && !err &&
Loading…
} {data && (
{data.displayName || data.username}
@{data.username} · #{data.id} · {data.email ?? 'no email'}
{data.online && Online} {data.bannedUntil && new Date(data.bannedUntil) > new Date() && ( Banned · expires {relativeTime(data.bannedUntil)} )} {data.isAdmin && Admin} {data.isDeveloper && Developer} {data.isCommunityTeam && Community Team} {data.isVerified && Verified} {data.isJunior && Junior}
{(['overview', 'mod', 'grants', 'gift', 'profile'] as const).map(t => ( ))}
{tab === 'overview' && } {tab === 'mod' && } {tab === 'grants' && } {tab === 'gift' && } {tab === 'profile' && }
)}
); } function Stat({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) { return (
{label}
{value}
); } function OverviewTab({ data }: { data: PlayerDetail }) { return (
no bio} />
Wallet
{data.balances.length === 0 ? (
No currency balances on file.
) : (
{data.balances.map(b => (
{currencyName(b.currencyType)}
{num(b.balance)}
))}
)}
); } function Field({ label, value, full, mono }: { label: string; value: React.ReactNode; full?: boolean; mono?: boolean }) { return (
{label}
{value}
); } // ── Moderation actions: ban, unban, kick, promote, demote ──────────── function ModerationTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) { const toast = useToast(); const [banDays, setBanDays] = useState(7); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(null); const [confirmKey, setConfirmKey] = useState(null); const run = async (label: string, fn: () => Promise) => { setBusy(label); try { await fn(); toast.push(`${label} succeeded`, 'success'); onChanged(); } catch (e) { toast.push((e as Error).message, 'error'); } finally { setBusy(null); } }; const banned = data.bannedUntil && new Date(data.bannedUntil) > new Date(); return (

Ban / unban

{banned ? `Currently banned until ${absoluteTime(data.bannedUntil)}.` : 'Temporary ban with optional reason.'}

setBanDays(parseInt(e.target.value || '1'))} className="input w-24" /> days
setReason(e.target.value)} placeholder="Reason (optional, logged)" className="input mb-2 text-xs" />
{banned && ( )}
{data.isAdmin &&

Demote this admin first.

}

Kick

Sends a ModerationKick push so the watch boots out of its current session. The player can reconnect immediately.

setReason(e.target.value)} placeholder="Reason (optional)" className="input mb-2 text-xs" />

Admin role

Admins bypass the ban check and can use this console. Don't demote yourself — there's a server-side guard but it's safer to have another admin do it.

{!data.isAdmin && ( )} {data.isAdmin && ( )}
setConfirmKey(null)} title="Ban player" body={`Ban @${data.username} for ${banDays} day${banDays === 1 ? '' : 's'}?`} confirmLabel="Ban" destructive onConfirm={() => run('Ban', () => api(`/players/${data.id}/ban`, { method: 'POST', body: { DurationDays: banDays, Reason: reason } }))} /> setConfirmKey(null)} title="Unban player" body={`Lift the ban on @${data.username}?`} confirmLabel="Unban" onConfirm={() => run('Unban', () => api(`/players/${data.id}/unban`, { method: 'POST', body: { Reason: reason } }))} /> setConfirmKey(null)} title="Kick player" body={`Boot @${data.username} from their current session?`} confirmLabel="Kick" onConfirm={() => run('Kick', () => api(`/players/${data.id}/kick`, { method: 'POST', body: { Reason: reason } }))} /> setConfirmKey(null)} title="Promote to admin" body={`Grant admin privileges to @${data.username}? They'll be able to use this console.`} confirmLabel="Promote" onConfirm={() => run('Promote', () => api(`/players/${data.id}/promote`, { method: 'POST' }))} /> setConfirmKey(null)} title="Revoke admin" body={`Strip admin privileges from @${data.username}?`} confirmLabel="Demote" destructive onConfirm={() => run('Demote', () => api(`/players/${data.id}/demote`, { method: 'POST' }))} />
); } // ── Grants: currency, XP, item, displayName, flags ─────────────────── function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) { const toast = useToast(); const run = async (label: string, fn: () => Promise) => { try { await fn(); toast.push(`${label} applied`, 'success'); onChanged(); } catch (e) { toast.push((e as Error).message, 'error'); } }; const [currType, setCurrType] = useState(2); const [currAmount, setCurrAmount] = useState(1000); const [xp, setXp] = useState(1000); const [targetLevel, setTargetLevel] = useState(data.level); const [itemId, setItemId] = useState(''); const [itemQty, setItemQty] = useState(1); return (

Adjust currency

Positive grants, negative deducts (clamped to zero).

setCurrAmount(parseInt(e.target.value || '0'))} className="input flex-1" />

Grant XP

May trigger level-up rewards.

setXp(parseInt(e.target.value || '0'))} className="input" />

Set level

Moves XP to the floor for that level.

setTargetLevel(parseInt(e.target.value || '1'))} className="input" />

Unlock cheer badges

Drops one cheer of each category (General, Helpful, Great Host, Sportsman, Creative, Credit) into the player's received-cheer list so they can pin any badge from the watch's Selected Cheer picker. Idempotent — re-running only fills in categories they don't already have. No XP is awarded.

Unlock all items

Grants the player the entire catalog: every avatar cosmetic, every permanent hair dye, and every store equipment/consumable. Idempotent — only adds what's missing. The watch's wardrobe only displays up to ~120 owned cosmetics at once (hard client cap), but all are granted and store items show as owned.

Grant inventory item

Appends an item GUID to the player's inventory JSON. Use the Store catalog to find slugs / GUIDs.

setItemId(e.target.value)} placeholder="item GUID" className="input font-mono text-xs" /> setItemQty(parseInt(e.target.value || '1'))} className="input" />
); } // ── Profile: display name + role flags ─────────────────────────────── function ProfileTab({ data, onChanged, onDeleted }: { data: PlayerDetail; onChanged: () => void; onDeleted: () => void }) { const toast = useToast(); const [displayName, setDisplayName] = useState(data.displayName); const [username, setUsername] = useState(data.username); const [deleteOpen, setDeleteOpen] = useState(false); const [flags, setFlags] = useState({ isVerified: data.isVerified, isDeveloper: data.isDeveloper, isCommunityTeam: data.isCommunityTeam, isJunior: data.isJunior, }); const save = async (label: string, fn: () => Promise) => { try { await fn(); toast.push(`${label} saved`, 'success'); onChanged(); } catch (e) { toast.push((e as Error).message, 'error'); } }; return (

Username

The unique @handle. 2–24 chars: letters, numbers, _ or -. Changing it doesn't touch the display name.

setUsername(e.target.value)} className="input flex-1 font-mono text-xs" />

Display name

Force-override the player's display name.

setDisplayName(e.target.value)} className="input flex-1" />

Profile flags

Verified = blue check on profile. Developer or Community Team unlocks the watch's overhead-badge slider — once unlocked, the player picks "Developer" or "Community Team" from their in-watch settings and the label renders above their head for everyone in the room.

{([ ['isVerified', 'Verified'], ['isDeveloper', 'Developer'], ['isCommunityTeam', 'Community Team'], ['isJunior', 'Junior'], ] as const).map(([k, label]) => ( ))}

Reset avatar

Wipes the player's equipped outfit and inventory back to the 2020 starter set. Use this when the watch crashes loading the avatar — usually means an item GUID in their inventory has no matching outfits_assets_*.bundle on disk (the build can't load it and Unity's Addressables tears down the local player).

Remove account

Deletes the account and personal rows. Rooms, inventions, clubs, and other durable authored content are reassigned to the system account.

{data.isAdmin &&

Demote this admin before removing the account.

} {data.id === 1 &&

The system account cannot be removed.

}
setDeleteOpen(false)} onDeleted={onDeleted} />
); } function DeleteAccountModal({ open, data, onClose, onDeleted }: { open: boolean; data: PlayerDetail; onClose: () => void; onDeleted: () => void; }) { const toast = useToast(); const [confirmUsername, setConfirmUsername] = useState(''); const [confirmPhrase, setConfirmPhrase] = useState(''); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const expectedPhrase = `DELETE ${data.id}`; const valid = confirmUsername.trim() === data.username && confirmPhrase.trim() === expectedPhrase; const remove = async () => { if (!valid) return; setBusy(true); try { await api(`/players/${data.id}`, { method: 'DELETE', body: { ConfirmUsername: confirmUsername.trim(), ConfirmPhrase: confirmPhrase.trim(), Reason: reason.trim() || null, }, }); toast.push(`@${data.username} removed`, 'success'); onClose(); onDeleted(); } catch (e) { toast.push((e as Error).message, 'error'); } finally { setBusy(false); } }; return ( } >

This removes @{data.username} and cannot be undone from the admin UI.

); }