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.
This commit is contained in:
Alexa
2026-05-27 01:06:12 +03:00
commit d471b418b9
389 changed files with 104767 additions and 0 deletions
@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { AuditEntry } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { Empty } from '../components/Empty';
import { absoluteTime, clip, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons';
export function Audit() {
const [take, setTake] = useState(200);
const { data, loading, error, refresh } = useApi<AuditEntry[]>(`/audit?take=${take}`);
return (
<div>
<PageHeader
title="Audit log"
blurb="Every admin action with actor, target, and reason. Newest first."
actions={<>
<select value={take} onChange={e => setTake(parseInt(e.target.value))} className="input w-24 text-xs !py-1.5">
<option value={100}>last 100</option>
<option value={200}>last 200</option>
<option value={500}>last 500</option>
</select>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</>}
/>
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>}
<div className="card overflow-hidden">
{data && data.length === 0 && <Empty title="No audit entries yet" />}
{data && data.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800 sticky top-0">
<tr>
<th className="text-left font-medium px-4 py-2.5">When</th>
<th className="text-left font-medium px-4 py-2.5">Admin</th>
<th className="text-left font-medium px-4 py-2.5">Action</th>
<th className="text-left font-medium px-4 py-2.5">Target</th>
<th className="text-left font-medium px-4 py-2.5">Reason</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{data.map(e => (
<tr key={e.id} className="table-row-hover">
<td className="px-4 py-2 text-ink-300 text-xs whitespace-nowrap" title={absoluteTime(e.timestamp)}>
{relativeTime(e.timestamp)}
</td>
<td className="px-4 py-2 text-ink-200">#{e.adminPlayerId}</td>
<td className="px-4 py-2">
<span className="font-mono text-xs text-brand-200 bg-brand-500/10 px-1.5 py-0.5 rounded">{e.action}</span>
</td>
<td className="px-4 py-2 text-ink-200">
<span className="text-ink-400">{e.targetType}</span>
{e.targetId > 0 && <> · #{e.targetId}</>}
</td>
<td className="px-4 py-2 text-ink-300 text-xs" title={e.reason}>{clip(e.reason, 100)}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useState } from 'react';
import { api } from '../lib/api';
import type { IpBan, Player } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Empty } from '../components/Empty';
import { absoluteTime, relativeTime } from '../lib/format';
import { RefreshCw, Trash } from '../components/Icons';
export function Bans() {
const [tab, setTab] = useState<'players' | 'ip'>('players');
return (
<div>
<PageHeader
title="Bans"
blurb="Player bans and IP-level bans, all in one place."
/>
<div className="flex border-b border-ink-800 text-sm mb-4">
{(['players', 'ip'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 -mb-px border-b-2 ${tab === t ? 'border-brand-500 text-ink-50' : 'border-transparent text-ink-300 hover:text-ink-100'}`}
>
{t === 'players' ? 'Player bans' : 'IP bans'}
</button>
))}
</div>
{tab === 'players' && <PlayerBans />}
{tab === 'ip' && <IpBans />}
</div>
);
}
function PlayerBans() {
// Pull a generous page of players and filter client-side for currently-banned.
// For a small private server this is fine; if we ever scale we can add a
// dedicated /players/banned endpoint.
const { data, loading, error, refresh } = useApi<Player[]>('/players?take=200');
const now = Date.now();
const banned = (data ?? []).filter(p => p.bannedUntil && new Date(p.bannedUntil).getTime() > now);
const toast = useToast();
const unban = async (id: number) => {
try {
await api(`/players/${id}/unban`, { method: 'POST', body: { Reason: 'admin-unban' } });
toast.push('Unbanned', 'success');
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-ink-800 px-4 py-2.5">
<div className="text-xs text-ink-400">{banned.length} active player ban{banned.length === 1 ? '' : 's'}</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>}
{!error && banned.length === 0 && <Empty title="No active player bans" blurb="Issued bans appear here until they expire or are lifted." />}
{banned.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
<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-left font-medium px-4 py-2.5">Expires</th>
<th className="text-left font-medium px-4 py-2.5">Last IP</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{banned.map(p => (
<tr key={p.id} className="table-row-hover">
<td className="px-4 py-2.5">
<div className="font-medium text-ink-50">{p.displayName || p.username}</div>
<div className="text-xs text-ink-400">@{p.username} · #{p.id}</div>
</td>
<td className="px-4 py-2.5 text-ink-200 text-xs" title={absoluteTime(p.bannedUntil)}>
{relativeTime(p.bannedUntil)} <span className="text-ink-500">({absoluteTime(p.bannedUntil)})</span>
</td>
<td className="px-4 py-2.5 text-ink-300 font-mono text-xs">{p.lastIp ?? '—'}</td>
<td className="px-4 py-2.5 text-right">
<button onClick={() => unban(p.id)} className="btn-secondary text-xs">Unban</button>
</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
);
}
function IpBans() {
const { data, loading, error, refresh } = useApi<IpBan[]>('/ipbans');
const toast = useToast();
const [cidr, setCidr] = useState('');
const [reason, setReason] = useState('');
const [days, setDays] = useState<number | ''>('');
const add = async (e: React.FormEvent) => {
e.preventDefault();
if (!cidr.trim()) return;
try {
await api('/ipbans', { method: 'POST', body: { Cidr: cidr.trim(), Reason: reason, DurationDays: days || null } });
setCidr(''); setReason(''); setDays('');
toast.push('IP ban added', 'success');
refresh();
} catch (err) {
toast.push((err as Error).message, 'error');
}
};
const remove = async (id: number) => {
if (!confirm('Remove this IP ban?')) return;
try {
await api(`/ipbans/${id}`, { method: 'DELETE' });
toast.push('IP ban removed', 'success');
refresh();
} catch (err) {
toast.push((err as Error).message, 'error');
}
};
const active = (b: IpBan) => !b.until || new Date(b.until).getTime() > Date.now();
return (
<div className="space-y-4">
<form onSubmit={add} className="card !p-4 grid grid-cols-1 md:grid-cols-[1fr,1fr,140px,auto] gap-2">
<input value={cidr} onChange={e => setCidr(e.target.value)} placeholder="1.2.3.4 or 10.0.0.0/24" required className="input" />
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (logged)" className="input" />
<input
type="number"
min={1}
max={3650}
value={days}
onChange={e => setDays(e.target.value === '' ? '' : parseInt(e.target.value))}
placeholder="days (∞)"
className="input"
/>
<button className="btn-primary text-xs">Add ban</button>
</form>
<div className="card overflow-hidden">
<div className="flex items-center justify-between border-b border-ink-800 px-4 py-2.5">
<div className="text-xs text-ink-400">{data ? `${data.length} total · ${data.filter(active).length} active` : ''}</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 IP bans" blurb="Add a CIDR above to start blocking ranges." />}
{data && data.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
<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">CIDR</th>
<th className="text-left font-medium px-4 py-2.5">Reason</th>
<th className="text-left font-medium px-4 py-2.5">Issued</th>
<th className="text-left font-medium px-4 py-2.5">Expires</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{data.map(b => (
<tr key={b.id} className="table-row-hover">
<td className="px-4 py-2.5 font-mono text-xs text-ink-100">{b.cidr}</td>
<td className="px-4 py-2.5 text-ink-300">{b.reason || '—'}</td>
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(b.bannedAt)}>{relativeTime(b.bannedAt)}</td>
<td className="px-4 py-2.5 text-ink-300 text-xs">
{b.until ? <span title={absoluteTime(b.until)}>{relativeTime(b.until)}</span> : <span className="text-ink-500">∞</span>}
</td>
<td className="px-4 py-2.5 text-right">
<button onClick={() => remove(b.id)} className="btn-ghost text-xs text-danger">
<Trash /> Remove
</button>
</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { api } from '../lib/api';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
export function Broadcast() {
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast();
const send = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
if (!confirm(`Broadcast this to every connected player?\n\n"${message}"`)) return;
setBusy(true);
try {
await api('/broadcast', { method: 'POST', body: { Message: message } });
toast.push('Broadcast sent', 'success');
setMessage('');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<div>
<PageHeader
title="Broadcast"
blurb="Push a server-maintenance notification to every connected player. The watch shows it as a system toast."
/>
<form onSubmit={send} className="card !p-5 max-w-2xl">
<label className="label">Message</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
rows={5}
className="input mt-1.5 font-mono text-sm"
placeholder="Server going down for maintenance in 5 minutes — log out cleanly to avoid losing your dorm save."
required
/>
<div className="mt-2 text-xs text-ink-400">
{message.length} characters
</div>
<div className="mt-4 flex gap-2 justify-end">
<button type="button" onClick={() => setMessage('')} className="btn-ghost text-xs" disabled={busy || !message}>Clear</button>
<button className="btn-primary text-xs" disabled={busy || !message.trim()}>
{busy ? 'Sending…' : 'Send broadcast'}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,671 @@
import { useEffect, useRef, useState } from 'react';
import { api, get } from '../lib/api';
import type { CommunityBoardState, Player, Room } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Plus, Trash } from '../components/Icons';
export function CommunityBoard() {
const { data: roomList } = useApi<Room[]>('/rooms');
const { data: players } = useApi<Player[]>('/players?take=500');
const [state, setState] = useState<CommunityBoardState | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const toast = useToast();
useEffect(() => {
get<CommunityBoardState>('/communityboard').then(setState).catch(e => setErr((e as Error).message));
}, []);
if (err) return <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
if (!state) return <div className="py-10 text-center text-xs text-ink-400">Loading…</div>;
const save = async () => {
setBusy(true);
try {
// Server-side `CommunityBoardService.UpdateAsync` replaces every
// field — null nested objects clear that section. We send the
// whole state on every save, no PATCH semantics.
const saved = await api<CommunityBoardState>('/communityboard', { method: 'POST', body: state });
setState(saved);
toast.push('Community board saved', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<div>
<PageHeader
title="Community board"
blurb="The dorm community panel the watch fetches from /api/communityboard/v1/current. Edits go live instantly."
actions={<button onClick={save} disabled={busy} className="btn-primary text-xs">{busy ? 'Saving…' : 'Save all'}</button>}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Announcement state={state} setState={setState} />
<FeaturedPlayer state={state} setState={setState} players={players ?? []} />
<FeaturedRoomGroup state={state} setState={setState} rooms={roomList ?? []} />
<InstagramImages state={state} setState={setState} />
<Videos state={state} setState={setState} />
</div>
</div>
);
}
// ── Section panels ───────────────────────────────────────────────────
interface SectionProps {
state: CommunityBoardState;
setState: (s: CommunityBoardState) => void;
}
function Announcement({ state, setState }: SectionProps) {
const a = state.currentAnnouncement;
return (
<Panel title="Announcement" blurb="Top-of-board banner. Clear to hide.">
<label className="flex items-center gap-2 text-sm text-ink-200 mb-2">
<input
type="checkbox"
checked={!!a}
onChange={e => setState({ ...state, currentAnnouncement: e.target.checked ? { message: '', moreInfoUrl: '' } : null })}
className="size-4 accent-brand-500"
/>
Show announcement
</label>
{a && (
<div className="space-y-2">
<Field label="Message">
<textarea
value={a.message}
onChange={e => setState({ ...state, currentAnnouncement: { ...a, message: e.target.value } })}
rows={2}
className="input"
/>
</Field>
<Field label="More-info URL (optional)">
<input
value={a.moreInfoUrl}
onChange={e => setState({ ...state, currentAnnouncement: { ...a, moreInfoUrl: e.target.value } })}
className="input"
placeholder="https://…"
/>
</Field>
</div>
)}
</Panel>
);
}
function FeaturedPlayer({ state, setState, players }: SectionProps & { players: Player[] }) {
const fp = state.featuredPlayer;
return (
<Panel title="Featured player" blurb="One spotlit account on the board.">
<label className="flex items-center gap-2 text-sm text-ink-200 mb-2">
<input
type="checkbox"
checked={!!fp}
onChange={e => setState({ ...state, featuredPlayer: e.target.checked ? { id: 0, titleOverride: '', urlOverride: '' } : null })}
className="size-4 accent-brand-500"
/>
Feature a player
</label>
{fp && (
<div className="space-y-2">
<Field label="Player">
<select
value={fp.id || ''}
onChange={e => setState({ ...state, featuredPlayer: { ...fp, id: parseInt(e.target.value || '0') } })}
className="input"
>
<option value="">— pick a player —</option>
{players.map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</Field>
<Field label="Title override (optional)">
<input
value={fp.titleOverride}
onChange={e => setState({ ...state, featuredPlayer: { ...fp, titleOverride: e.target.value } })}
className="input"
/>
</Field>
<Field label="URL override (optional)">
<input
value={fp.urlOverride}
onChange={e => setState({ ...state, featuredPlayer: { ...fp, urlOverride: e.target.value } })}
className="input"
placeholder="https://…"
/>
</Field>
</div>
)}
</Panel>
);
}
function FeaturedRoomGroup({ state, setState, rooms }: SectionProps & { rooms: Room[] }) {
const g = state.featuredRoomGroup;
return (
<Panel title="Featured rooms" blurb="Card carousel labeled with the group name.">
<label className="flex items-center gap-2 text-sm text-ink-200 mb-2">
<input
type="checkbox"
checked={!!g}
onChange={e => setState({ ...state, featuredRoomGroup: e.target.checked ? { name: '', featuredRooms: [] } : null })}
className="size-4 accent-brand-500"
/>
Feature a room group
</label>
{g && (
<div className="space-y-2">
<Field label="Group name">
<input value={g.name} onChange={e => setState({ ...state, featuredRoomGroup: { ...g, name: e.target.value } })} className="input" placeholder="Staff picks" />
</Field>
<div>
<div className="label mb-1.5">Rooms ({g.featuredRooms.length})</div>
<div className="flex flex-col gap-1.5">
{g.featuredRooms.map((id, i) => (
<div key={i} className="flex items-center gap-2">
<select
value={id || ''}
onChange={e => {
const next = [...g.featuredRooms];
next[i] = parseInt(e.target.value || '0');
setState({ ...state, featuredRoomGroup: { ...g, featuredRooms: next } });
}}
className="input flex-1"
>
<option value="">— pick a room —</option>
{rooms.map(r => <option key={r.id} value={r.id}>{r.name} · #{r.id}</option>)}
</select>
<button
type="button"
onClick={() => setState({ ...state, featuredRoomGroup: { ...g, featuredRooms: g.featuredRooms.filter((_, j) => j !== i) } })}
className="btn-ghost text-xs text-danger"
>
<Trash />
</button>
</div>
))}
<button
type="button"
onClick={() => setState({ ...state, featuredRoomGroup: { ...g, featuredRooms: [...g.featuredRooms, 0] } })}
className="btn-secondary text-xs self-start"
>
<Plus /> Add room
</button>
</div>
</div>
</div>
)}
</Panel>
);
}
function InstagramImages({ state, setState }: SectionProps) {
return (
<Panel title="Instagram images" blurb="Pick an image to upload — the file gets stored as a RoomDataBlob and served via img.localhost. The optional URL field is the click-through target on the board.">
<div className="flex flex-col gap-2">
{state.instagramImages.map((img, i) => (
<InstagramImageRow
key={i}
img={img}
onChange={next => {
const arr = [...state.instagramImages];
arr[i] = next;
setState({ ...state, instagramImages: arr });
}}
onRemove={() => setState({ ...state, instagramImages: state.instagramImages.filter((_, j) => j !== i) })}
/>
))}
<button
type="button"
onClick={() => setState({ ...state, instagramImages: [...state.instagramImages, { imageName: '', imageUrl: '' }] })}
className="btn-secondary text-xs self-start"
>
<Plus /> Add image
</button>
</div>
</Panel>
);
}
function InstagramImageRow({
img,
onChange,
onRemove,
}: {
img: { imageName: string; imageUrl: string };
onChange: (next: { imageName: string; imageUrl: string }) => void;
onRemove: () => void;
}) {
const toast = useToast();
const fileInput = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleFile = async (file: File) => {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.push('Please pick an image file', 'error');
return;
}
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await api<{ imageName: string; imageUrl: string; bytes: number; reused: boolean }>(
'/communityboard/instagram/upload',
{ method: 'POST', formData: fd, timeoutMs: 60_000 },
);
onChange({ imageName: res.imageName, imageUrl: img.imageUrl });
toast.push(res.reused ? 'Image already on file — reused' : `Uploaded (${(res.bytes / 1024).toFixed(0)} KB)`, 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setUploading(false);
if (fileInput.current) fileInput.current.value = '';
}
};
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
const preview = img.imageName ? `https://img.${apex}/${encodeURIComponent(img.imageName)}?width=128&sig=p1` : null;
return (
<div className="rounded border border-ink-800 p-2 flex gap-2 items-start">
<div
className="shrink-0 size-16 rounded bg-ink-900 border border-ink-800 overflow-hidden flex items-center justify-center cursor-pointer hover:border-brand-500"
onClick={() => fileInput.current?.click()}
title="Click to upload"
>
{preview ? (
<img src={preview} className="size-full object-cover" alt="" />
) : (
<span className="text-[10px] text-ink-500 px-1 text-center">{uploading ? 'Uploading…' : 'Pick image'}</span>
)}
</div>
<input
ref={fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
<div className="flex-1 flex flex-col gap-1.5">
<div className="flex gap-1.5 items-center">
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploading}
className="btn-secondary text-xs"
>
{uploading ? 'Uploading…' : preview ? 'Replace' : 'Upload'}
</button>
<span className="font-mono text-[11px] text-ink-400 truncate flex-1" title={img.imageName}>
{img.imageName || <span className="italic text-ink-600">no image yet</span>}
</span>
</div>
<input
value={img.imageUrl}
onChange={e => onChange({ ...img, imageUrl: e.target.value })}
placeholder="https://instagram.com/p/… (click-through URL)"
className="input text-xs"
/>
</div>
<button
type="button"
onClick={onRemove}
className="btn-ghost text-xs text-danger self-start"
>
<Trash />
</button>
</div>
);
}
function Videos({ state, setState }: SectionProps) {
return (
<Panel
title="Videos"
blurb="Upload an .mp4/.webm/.mov (≤100 MB) plus a thumbnail image — the files land in RoomDataBlobs and are served from cdn / img. Or skip the upload and paste a SourceUrl pointing at YouTube / etc. for an externally-hosted video."
>
<div className="flex flex-col gap-3">
{state.videos.map((v, i) => (
<VideoRow
key={i}
video={v}
onChange={next => {
const arr = [...state.videos];
arr[i] = next;
setState({ ...state, videos: arr });
}}
onRemove={() => setState({ ...state, videos: state.videos.filter((_, j) => j !== i) })}
/>
))}
<button
type="button"
onClick={() => setState({ ...state, videos: [...state.videos, { blobName: '', title: '', description: '', thumbnailBlobName: '', sourceUrl: '' }] })}
className="btn-secondary text-xs self-start"
>
<Plus /> Add video
</button>
</div>
</Panel>
);
}
// Capture a still frame from a video File and return it as a JPEG Blob.
// Strategy: spin up an off-DOM <video> element, point it at a blob URL
// so the bytes never leave the page, wait for the first decoded frame
// (`loadeddata`), nudge currentTime so most encoders give us a real
// I-frame (some MP4s render black at t=0 because the first sample is
// only a P-frame referencing nothing), draw to a canvas, then export.
//
// Width is capped at 1280 so we don't end up with a 30-MB poster image
// from a 4K source — the watch renders thumbnails at ~256 px anyway.
// Returns null if the browser refuses to decode (Chrome on Linux occas-
// ionally chokes on H.265/HEVC; H.264 + VP9 cover the rest).
async function captureFirstFrame(videoFile: File): Promise<Blob | null> {
const url = URL.createObjectURL(videoFile);
try {
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.src = url;
await new Promise<void>((resolve, reject) => {
const onReady = () => { cleanup(); resolve(); };
const onError = () => { cleanup(); reject(new Error('video decode failed')); };
const cleanup = () => {
video.removeEventListener('loadeddata', onReady);
video.removeEventListener('error', onError);
};
video.addEventListener('loadeddata', onReady);
video.addEventListener('error', onError);
});
// Seek a tenth of a second in — t=0 frames are often black with
// certain encoders. Wait for `seeked` so we draw real bytes.
if (video.duration && Number.isFinite(video.duration) && video.duration > 0.2) {
await new Promise<void>((resolve) => {
const onSeeked = () => { video.removeEventListener('seeked', onSeeked); resolve(); };
video.addEventListener('seeked', onSeeked);
video.currentTime = Math.min(0.1, video.duration / 2);
});
}
const maxWidth = 1280;
const ratio = Math.min(1, maxWidth / video.videoWidth);
const canvas = document.createElement('canvas');
canvas.width = Math.max(2, Math.round(video.videoWidth * ratio));
canvas.height = Math.max(2, Math.round(video.videoHeight * ratio));
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
return await new Promise<Blob | null>(resolve =>
canvas.toBlob(b => resolve(b), 'image/jpeg', 0.88));
} finally {
URL.revokeObjectURL(url);
}
}
// Single editable row for a community-board video. Mirrors
// InstagramImageRow's click-to-upload pattern with two pickers (the
// video itself + a thumbnail). The video upload caps at 100 MB to dodge
// Cloudflare's edge limit on non-chunked POSTs — anything larger should
// be hosted externally and pasted into SourceUrl.
function VideoRow({
video, onChange, onRemove,
}: {
video: { blobName: string; title: string; description: string; thumbnailBlobName: string; sourceUrl: string };
onChange: (next: typeof video) => void;
onRemove: () => void;
}) {
const toast = useToast();
const videoInput = useRef<HTMLInputElement>(null);
const thumbInput = useRef<HTMLInputElement>(null);
const [uploadingVideo, setUploadingVideo] = useState(false);
const [uploadingThumb, setUploadingThumb] = useState(false);
// Auto-grab the first frame of the uploaded video and use it as the
// thumbnail. On by default — admins almost always want this and can
// still upload a custom thumbnail afterwards (the custom one wins
// because it overwrites thumbnailBlobName).
const [autoThumb, setAutoThumb] = useState(true);
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
const thumbPreview = video.thumbnailBlobName ? `https://img.${apex}/${encodeURIComponent(video.thumbnailBlobName)}?width=160&sig=p1` : null;
// The watch fetches videos under cdn.{apex}/video/<BlobName>
// (RecNet/CommunityBoard.txt:1068 — `String.Concat("/video/", BlobName)`).
// Match that path here so the inline admin preview hits the same
// route the watch does; previously we built a bare `cdn.{apex}/<name>`
// URL which 404'd because CdnController's catch-all regex didn't
// include video extensions.
const videoUrl = video.blobName ? `https://cdn.${apex}/video/${encodeURIComponent(video.blobName)}` : null;
// Video file picker → POST /communityboard/video/upload (timeout
// generous: 100 MB at 5 MB/s is 20s of body time alone, plus S3
// PutObject latency on cold cache). If the auto-thumbnail toggle is
// on we extract the first frame BEFORE the upload kicks off — quick
// (sub-second for most files) and a failure there doesn't block the
// video upload itself; the admin can always pick a thumbnail by hand.
const handleVideoFile = async (file: File) => {
if (!file) return;
if (!file.type.startsWith('video/') && !/\.(mp4|webm|mov|m4v)$/i.test(file.name)) {
toast.push('Pick a video file (.mp4 / .webm / .mov / .m4v)', 'error');
return;
}
if (file.size > 100_000_000) {
toast.push('Max 100 MB — host larger files externally and use the SourceUrl field', 'error');
return;
}
setUploadingVideo(true);
try {
// Snapshot the first frame BEFORE we lose the local File reference.
// Store the resulting Blob — we'll upload it as a thumbnail after
// the video upload succeeds. Captured up front so a single video
// upload doesn't hold the spinner open while we extract a frame
// sequentially.
let framePromise: Promise<Blob | null> | null = null;
if (autoThumb) framePromise = captureFirstFrame(file).catch(() => null);
const fd = new FormData();
fd.append('file', file);
const res = await api<{ blobName: string; videoUrl: string; bytes: number; reused: boolean }>(
'/communityboard/video/upload',
{ method: 'POST', formData: fd, timeoutMs: 300_000 },
);
let next = { ...video, blobName: res.blobName };
toast.push(res.reused ? 'Video already on file — reused' : `Uploaded video (${(res.bytes / 1024 / 1024).toFixed(1)} MB)`, 'success');
// Upload the captured frame as the thumbnail. Skipped silently if
// the canvas grab failed (e.g. CORS-tainted decode on some
// browsers) so the video itself isn't blocked on an optional
// niceity.
if (framePromise) {
const frame = await framePromise;
if (frame) {
setUploadingThumb(true);
try {
const tfd = new FormData();
tfd.append('file', new File([frame], `${res.blobName.replace(/\.[a-z0-9]+$/i, '')}.jpg`, { type: 'image/jpeg' }));
const thumbRes = await api<{ blobName: string; imageUrl: string; bytes: number; reused: boolean }>(
'/communityboard/videothumb/upload',
{ method: 'POST', formData: tfd, timeoutMs: 60_000 },
);
next = { ...next, thumbnailBlobName: thumbRes.blobName };
toast.push(thumbRes.reused ? 'Frame thumbnail reused' : 'Captured first-frame thumbnail', 'success');
} catch (e) {
toast.push(`Auto-thumbnail failed: ${(e as Error).message}`, 'error');
} finally {
setUploadingThumb(false);
}
} else {
toast.push('Could not decode a frame for the thumbnail — pick one manually', 'error');
}
}
onChange(next);
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setUploadingVideo(false);
if (videoInput.current) videoInput.current.value = '';
}
};
// Thumbnail file picker → POST /communityboard/videothumb/upload.
// Separate endpoint from videos so admins don't accidentally upload a
// 50 MB poster image (which the watch only renders ~256px wide).
const handleThumbFile = async (file: File) => {
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.push('Pick an image file for the thumbnail', 'error');
return;
}
setUploadingThumb(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await api<{ blobName: string; imageUrl: string; bytes: number; reused: boolean }>(
'/communityboard/videothumb/upload',
{ method: 'POST', formData: fd, timeoutMs: 60_000 },
);
onChange({ ...video, thumbnailBlobName: res.blobName });
toast.push(res.reused ? 'Thumbnail already on file — reused' : `Uploaded thumbnail (${(res.bytes / 1024).toFixed(0)} KB)`, 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setUploadingThumb(false);
if (thumbInput.current) thumbInput.current.value = '';
}
};
return (
<div className="rounded border border-ink-800 p-3 space-y-2">
<div className="flex gap-3 items-start">
{/* Thumbnail tile — click-to-pick like InstagramImageRow */}
<div
className="shrink-0 size-20 rounded bg-ink-900 border border-ink-800 overflow-hidden flex items-center justify-center cursor-pointer hover:border-brand-500"
onClick={() => thumbInput.current?.click()}
title="Click to upload thumbnail"
>
{thumbPreview ? (
<img src={thumbPreview} className="size-full object-cover" alt="" />
) : (
<span className="text-[10px] text-ink-500 px-1 text-center">{uploadingThumb ? 'Uploading…' : 'Pick thumb'}</span>
)}
</div>
<input
ref={thumbInput}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleThumbFile(f); }}
/>
<div className="flex-1 flex flex-col gap-1.5">
<input
value={video.title}
onChange={e => onChange({ ...video, title: e.target.value })}
placeholder="Title"
className="input text-xs"
/>
<textarea
value={video.description}
onChange={e => onChange({ ...video, description: e.target.value })}
placeholder="Description"
rows={2}
className="input text-xs"
/>
</div>
<button
type="button"
onClick={onRemove}
className="btn-ghost text-xs text-danger self-start"
title="Remove video"
>
<Trash />
</button>
</div>
{/* Video file row + native preview if we have a blob to play */}
<div className="flex flex-col gap-1.5">
<div className="flex gap-1.5 items-center flex-wrap">
<button
type="button"
onClick={() => videoInput.current?.click()}
disabled={uploadingVideo}
className="btn-secondary text-xs"
>
{uploadingVideo ? 'Uploading…' : video.blobName ? 'Replace video' : 'Upload video'}
</button>
<input
ref={videoInput}
type="file"
accept="video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov,.m4v"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleVideoFile(f); }}
/>
{/* Auto-thumbnail toggle. Lives next to the upload button so
the admin sets it BEFORE clicking — checking it after the
upload is too late, the File reference is gone. */}
<label className="flex items-center gap-1.5 text-[11px] text-ink-300 cursor-pointer" title="Capture the first decoded frame from the uploaded video and store it as the thumbnail.">
<input
type="checkbox"
checked={autoThumb}
onChange={e => setAutoThumb(e.target.checked)}
className="size-3.5 accent-brand-500"
/>
Auto-thumb from first frame
</label>
<span className="font-mono text-[11px] text-ink-400 truncate flex-1" title={video.blobName}>
{video.blobName || <span className="italic text-ink-600">no video uploaded (use SourceUrl for external hosts)</span>}
</span>
{video.blobName && (
<button
type="button"
onClick={() => onChange({ ...video, blobName: '' })}
className="btn-ghost text-[11px] py-0.5 px-1.5 text-danger"
title="Clear uploaded video"
>Clear</button>
)}
</div>
{videoUrl && (
<video
src={videoUrl}
controls
preload="metadata"
className="w-full max-h-48 rounded bg-black"
/>
)}
</div>
<input
value={video.sourceUrl}
onChange={e => onChange({ ...video, sourceUrl: e.target.value })}
placeholder="SourceUrl — external video URL (YouTube etc.). Leave blank if you uploaded above."
className="input text-xs"
/>
</div>
);
}
function Panel({ title, blurb, children }: { title: string; blurb?: string; children: React.ReactNode }) {
return (
<div className="card !p-4">
<h2 className="text-sm font-semibold text-ink-50">{title}</h2>
{blurb && <p className="text-xs text-ink-400 mb-3">{blurb}</p>}
<div>{children}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="label">{label}</span>
{children}
</label>
);
}
@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { get } from '../lib/api';
import type { Stats } from '../lib/types';
import { RefreshCw } from '../components/Icons';
export function Dashboard() {
const [stats, setStats] = useState<Stats | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
setErr(null);
try {
const data = await get<Stats>('/stats');
setStats(data);
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
const id = setInterval(load, 10_000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="space-y-6">
<div className="flex items-end justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">Dashboard</h1>
<p className="text-sm text-ink-400">Server snapshot, refreshed every 10s.</p>
</div>
<button onClick={load} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{err && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Stat label="Players online" value={stats?.players.onlineNow} accent="success" sub={stats && `${stats.players.total} total`} />
<Stat label="Banned now" value={stats?.players.bannedNow} accent="danger" sub={stats && `${stats.moderation.activeIpBans} IP bans`} />
<Stat label="Open reports" value={stats?.moderation.openReports} accent="warn" />
<Stat label="Total rooms" value={stats?.rooms.total} accent="neutral" sub={stats && `${stats.inventions} inventions`} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-ink-100">Top rooms by visits</h2>
<p className="text-xs text-ink-400 mb-3">All-time visit counts.</p>
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
<tr>
<th className="text-left font-medium pb-2">Room</th>
<th className="text-right font-medium pb-2">Visits</th>
<th className="text-right font-medium pb-2">Visitors</th>
<th className="text-right font-medium pb-2">Cheers</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{stats?.rooms.topByVisits.map(r => (
<tr key={r.id} className="table-row-hover">
<td className="py-2 text-ink-100">{r.name}</td>
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitCount.toLocaleString()}</td>
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitorCount.toLocaleString()}</td>
<td className="py-2 text-right text-ink-200 tabular-nums">{r.cheerCount.toLocaleString()}</td>
</tr>
))}
{!stats && <tr><td colSpan={4} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
</tbody>
</table></div>
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-ink-100">Recent signups</h2>
<p className="text-xs text-ink-400 mb-3">Latest 10 accounts.</p>
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
<tr>
<th className="text-left font-medium pb-2">Username</th>
<th className="text-left font-medium pb-2">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{stats?.recentJoins.map(j => (
<tr key={j.id} className="table-row-hover">
<td className="py-2 text-ink-100">{j.username} <span className="text-ink-500 text-xs">#{j.id}</span></td>
<td className="py-2 text-ink-300 text-xs">{new Date(j.createdAt).toLocaleString()}</td>
</tr>
))}
{!stats && <tr><td colSpan={2} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
</tbody>
</table></div>
</div>
</div>
<p className="text-[11px] text-ink-500">Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '—'}</p>
</div>
);
}
function Stat({ label, value, sub, accent }: { label: string; value: number | undefined; sub?: string | null; accent: 'success' | 'danger' | 'warn' | 'neutral' }) {
const accentRing = {
success: 'before:bg-success',
danger: 'before:bg-danger',
warn: 'before:bg-warn',
neutral: 'before:bg-brand-500',
}[accent];
return (
<div className={`card p-4 relative overflow-hidden before:absolute before:left-0 before:top-0 before:h-full before:w-0.5 ${accentRing}`}>
<div className="text-[11px] uppercase tracking-widest text-ink-400">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums text-ink-50">{value?.toLocaleString() ?? '—'}</div>
{sub && <div className="mt-0.5 text-xs text-ink-400">{sub}</div>}
</div>
);
}
+436
View File
@@ -0,0 +1,436 @@
import { useEffect, useMemo, useState } from 'react';
import { api, get } from '../lib/api';
import type { Player } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm';
import { RefreshCw, Search, Trash } from '../components/Icons';
import { relativeTime } from '../lib/format';
// Avatar-item slot ids from the watch enum (RecRoom.Avatar.Data.OutfitType
// in the decompiled client).
const SLOT_NAMES: Record<number, string> = {
0: 'Head', 1: 'Face', 2: 'Hair', 3: 'Torso',
4: 'Legs', 5: 'Feet', 6: 'Accessory', 7: 'Hand item',
};
// GiftRarity: RecNet.Avatars+GiftRarity. The 2020 watch tints the
// gift-box particle effects + post-open VFX from this value, so it's
// worth letting admins pick.
const RARITY_OPTIONS: Array<{ value: number; label: string }> = [
{ value: 0, label: 'Common' },
{ value: 10, label: 'Uncommon' },
{ value: 20, label: 'Rare' },
{ value: 30, label: 'Epic' },
{ value: 50, label: 'Legendary' },
];
interface AvatarItem {
guid: string;
slot: number;
friendlyName: string;
tooltip: string;
rarity: number;
safe: boolean;
}
// Gifting fires the in-game gift-box popup by inserting a GiftPackageEntity
// row + pushing GiftPackageReceived. The watch's DowloadGiftPackages loop
// picks it up on the next poll, the player sees the popup, taps "open",
// and the consume endpoint writes the avatar item into their inventory.
// This is the only flow that triggers the popup — directly poking
// InventoryJson skips it entirely.
export function Gift() {
const { data: players } = useApi<Player[]>('/players?take=500');
const [playerId, setPlayerId] = useState<number | null>(null);
const [includeItem, setIncludeItem] = useState(true);
const [includeCurrency, setIncludeCurrency] = useState(false);
const [includeXp, setIncludeXp] = useState(false);
const [item, setItem] = useState<AvatarItem | null>(null);
const [manualGuid, setManualGuid] = useState('');
const [avatarItemType, setAvatarItemType] = useState(0); // 0 Outfit, 1 HairDye
const [currencyType, setCurrencyType] = useState(2);
const [currencyAmount, setCurrencyAmount] = useState(1000);
const [xp, setXp] = useState(500);
const [rarity, setRarity] = useState(0);
const [message, setMessage] = useState('A gift from the admins.');
const [busy, setBusy] = useState(false);
const [pendingRefreshKey, setPendingRefreshKey] = useState(0);
const toast = useToast();
const send = async () => {
if (!playerId) return toast.push('Pick a recipient first', 'error');
if (!includeItem && !includeCurrency && !includeXp) {
return toast.push('Pick at least one reward (item / currency / xp)', 'error');
}
const guid = includeItem ? (manualGuid.trim() || item?.guid) : null;
if (includeItem && !guid) {
return toast.push('Pick an avatar item from the list, or paste a GUID', 'error');
}
setBusy(true);
try {
const res = await api<{ id: number }>(`/players/${playerId}/gift`, {
method: 'POST',
body: {
AvatarItemGuid: includeItem ? guid : null,
AvatarItemType: includeItem ? avatarItemType : null,
CurrencyType: includeCurrency ? currencyType : null,
Currency: includeCurrency ? currencyAmount : null,
Xp: includeXp ? xp : null,
Message: message.trim() || null,
Rarity: rarity,
},
});
toast.push(`Gift #${res.id} sent — the player will see the popup in-game`, 'success');
setManualGuid('');
setItem(null);
setPendingRefreshKey(k => k + 1);
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<div>
<PageHeader
title="Send a gift"
blurb="Drops a wrapped gift box on the recipient's HUD — they tap to open it and the rewards land in their inventory / wallet."
/>
{playerId !== null && <PendingGifts playerId={playerId} refreshKey={pendingRefreshKey} />}
<div className="grid grid-cols-1 lg:grid-cols-[1fr,360px] gap-4 max-w-5xl">
<div className="card !p-5 space-y-4">
<label className="flex flex-col gap-1">
<span className="label">Recipient</span>
<select value={playerId ?? ''} onChange={e => setPlayerId(e.target.value ? parseInt(e.target.value) : null)} className="input">
<option value="">— pick a player —</option>
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm text-ink-100 font-medium">
<input type="checkbox" checked={includeItem} onChange={e => setIncludeItem(e.target.checked)} className="size-4 accent-brand-500" />
Include avatar item
</label>
{includeItem && (
<div className="pl-6 space-y-2">
<div className="flex gap-2 items-center">
<span className="label">Type</span>
<select value={avatarItemType} onChange={e => setAvatarItemType(parseInt(e.target.value))} className="input max-w-[160px]">
<option value={0}>Outfit item</option>
<option value={1}>Hair dye</option>
</select>
</div>
<AvatarItemPicker selected={item} onSelect={(i) => { setItem(i); setManualGuid(''); }} />
<label className="flex flex-col gap-1">
<span className="label">Or paste a raw GUID</span>
<input
value={manualGuid}
onChange={e => { setManualGuid(e.target.value); setItem(null); }}
placeholder="00000000-0000-0000-0000-000000000000"
className="input font-mono text-xs"
/>
</label>
</div>
)}
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm text-ink-100 font-medium">
<input type="checkbox" checked={includeCurrency} onChange={e => setIncludeCurrency(e.target.checked)} className="size-4 accent-brand-500" />
Include currency
</label>
{includeCurrency && (
<div className="pl-6 grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="label">Currency</span>
<select value={currencyType} onChange={e => setCurrencyType(parseInt(e.target.value))} className="input">
<option value={1}>Tokens</option>
<option value={2}>Coins</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="label">Amount</span>
<input type="number" min={1} value={currencyAmount} onChange={e => setCurrencyAmount(parseInt(e.target.value || '0'))} className="input" />
</label>
</div>
)}
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm text-ink-100 font-medium">
<input type="checkbox" checked={includeXp} onChange={e => setIncludeXp(e.target.checked)} className="size-4 accent-brand-500" />
Include XP
</label>
{includeXp && (
<div className="pl-6">
<label className="flex flex-col gap-1">
<span className="label">XP amount</span>
<input type="number" min={1} value={xp} onChange={e => setXp(parseInt(e.target.value || '0'))} className="input w-32" />
</label>
</div>
)}
</div>
</div>
{/* Sidebar: presentation + send button. The watch tints the gift box
and applies VFX based on rarity, so it's a real cosmetic choice. */}
<div className="card !p-5 space-y-4 h-fit">
<h2 className="text-sm font-semibold text-ink-50">Presentation</h2>
<label className="flex flex-col gap-1">
<span className="label">Rarity (gift-box tint)</span>
<select value={rarity} onChange={e => setRarity(parseInt(e.target.value))} className="input">
{RARITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="label">Message (shown on the open card)</span>
<textarea value={message} onChange={e => setMessage(e.target.value)} rows={3} className="input" />
</label>
<button onClick={send} disabled={busy || !playerId} className="btn-primary w-full text-xs">
{busy ? 'Sending…' : 'Send gift'}
</button>
<div className="text-[11px] text-ink-500">
The recipient sees the gift box in their next HUD update (SignalR push) or when they next poll <code className="font-mono">/api/avatar/v2/gifts</code>.
</div>
</div>
</div>
</div>
);
}
function AvatarItemPicker({ selected, onSelect }: { selected: AvatarItem | null; onSelect: (i: AvatarItem) => void }) {
const { data: catalog, loading } = useApi<AvatarItem[]>('/avatar-items');
const [search, setSearch] = useState('');
const [slotFilter, setSlotFilter] = useState<number | 'all'>('all');
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
return (catalog ?? []).filter(it => {
if (slotFilter !== 'all' && it.slot !== slotFilter) return false;
if (term && !it.friendlyName.toLowerCase().includes(term) && !it.guid.toLowerCase().includes(term)) return false;
return true;
});
}, [catalog, search, slotFilter]);
const slots = useMemo(() => Array.from(new Set((catalog ?? []).map(c => c.slot))).sort((a, b) => a - b), [catalog]);
return (
<div>
<div className="flex flex-wrap gap-2 items-end mb-2">
<div className="relative flex-1 min-w-[180px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search by name or GUID…" className="input pl-8" />
</div>
<select value={slotFilter} onChange={e => setSlotFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))} className="input max-w-[140px]">
<option value="all">All slots</option>
{slots.map(s => <option key={s} value={s}>{SLOT_NAMES[s] ?? `Slot #${s}`}</option>)}
</select>
</div>
<div className="rounded-lg border border-ink-800 max-h-64 overflow-y-auto">
{loading && <div className="p-3 text-xs text-ink-400">Loading catalog…</div>}
{catalog && filtered.length === 0 && <div className="p-3 text-xs text-ink-400">No items match.</div>}
{filtered.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[560px]">
<tbody className="divide-y divide-ink-800">
{filtered.slice(0, 200).map(it => (
<tr
key={it.guid}
onClick={() => onSelect(it)}
className={`cursor-pointer table-row-hover ${selected?.guid === it.guid ? 'bg-brand-500/10' : ''}`}
>
<td className="px-3 py-1.5">
<div className="text-ink-100 text-sm">{it.friendlyName || <span className="text-ink-500">unnamed</span>}</div>
<div className="font-mono text-[10px] text-ink-500">{it.guid}</div>
</td>
<td className="px-3 py-1.5 text-xs text-ink-300 whitespace-nowrap">{SLOT_NAMES[it.slot] ?? `slot ${it.slot}`}</td>
</tr>
))}
{filtered.length > 200 && (
<tr><td colSpan={2} className="p-3 text-center text-xs text-ink-500">+ {filtered.length - 200} more — refine the search.</td></tr>
)}
</tbody>
</table></div>
)}
</div>
</div>
);
}
// ── Pending gift inbox for the selected recipient ───────────────────
// Surfaces any unconsumed gifts on the recipient's account so we can
// see what's actually queued and delete broken / abandoned entries
// from earlier test sends. IsGifted-flagged or otherwise malformed
// rows might still be in the inbox refusing to render — clear them
// here.
interface PendingGift {
id: number;
fromPlayerId: number | null;
avatarItemType: number;
avatarItemDescOrHairDyeDesc: string;
currencyType: number;
currency: number;
xp: number;
level: number;
giftContext: number;
giftRarity: number;
message: string;
consumed: boolean;
isValid: boolean;
isGifted: boolean;
createdAt: string;
}
function PendingGifts({ playerId, refreshKey }: { playerId: number; refreshKey: number }) {
const [rows, setRows] = useState<PendingGift[] | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const [pendingDelete, setPendingDelete] = useState<PendingGift | null>(null);
const toast = useToast();
const load = async () => {
setLoading(true);
setErr(null);
try {
const data = await get<PendingGift[]>(`/players/${playerId}/gifts`);
setRows(data);
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [playerId, refreshKey]);
const delOne = async () => {
if (!pendingDelete) return;
try {
await api(`/gifts/${pendingDelete.id}`, { method: 'DELETE' });
toast.push(`Deleted gift #${pendingDelete.id}`, 'success');
load();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const clearAll = async () => {
try {
const res = await api<{ removed: number }>(`/players/${playerId}/gifts/clear`, { method: 'POST' });
toast.push(`Removed ${res.removed} pending gift${res.removed === 1 ? '' : 's'}`, 'success');
load();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
// Most of the broken test-gifts will have IsGifted=true or empty
// payloads — flag those so the user can spot stuck rows at a glance.
const looksBroken = (g: PendingGift) =>
g.isGifted
|| !g.isValid
|| (g.avatarItemType === 0
&& !g.avatarItemDescOrHairDyeDesc
&& g.currency === 0
&& g.xp === 0
&& g.level === 0);
return (
<div className="card !p-4 mb-4 max-w-5xl">
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-semibold text-ink-50">Pending gifts on this player</h2>
<p className="text-xs text-ink-400">Unconsumed gift packages currently in the recipient's inbox. Delete anything stuck.</p>
</div>
<div className="flex items-center gap-2">
<button onClick={load} className="btn-ghost text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
{rows && rows.length > 0 && (
<button onClick={() => setConfirmClear(true)} className="btn-danger text-xs">
Clear all ({rows.length})
</button>
)}
</div>
</div>
{err && <div className="text-xs text-danger">{err}</div>}
{!err && rows && rows.length === 0 && (
<div className="text-xs text-ink-500 py-2">Inbox is empty.</div>
)}
{rows && rows.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[560px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
<tr>
<th className="text-left font-medium pb-2">#</th>
<th className="text-left font-medium pb-2">Contents</th>
<th className="text-left font-medium pb-2">From</th>
<th className="text-left font-medium pb-2">Created</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{rows.map(g => (
<tr key={g.id} className={`table-row-hover ${looksBroken(g) ? 'bg-danger/5' : ''}`}>
<td className="py-2 text-ink-400 tabular-nums">{g.id}</td>
<td className="py-2 text-ink-100">
<div className="flex flex-wrap gap-1">
{g.avatarItemDescOrHairDyeDesc && (
<span className="badge-neutral font-mono">item</span>
)}
{g.currency > 0 && (
<span className="badge-neutral">{g.currency.toLocaleString()} {g.currencyType === 1 ? 'tokens' : 'coins'}</span>
)}
{g.xp > 0 && <span className="badge-neutral">{g.xp} xp</span>}
{g.isGifted && <span className="badge-banned">IsGifted=true (broken)</span>}
{!g.isValid && <span className="badge-banned">IsValid=false</span>}
</div>
{g.message && <div className="text-xs text-ink-400 mt-0.5">{g.message}</div>}
</td>
<td className="py-2 text-xs text-ink-300">{g.fromPlayerId ? `#${g.fromPlayerId}` : <span className="text-ink-500">system</span>}</td>
<td className="py-2 text-xs text-ink-300">{relativeTime(g.createdAt)}</td>
<td className="py-2 text-right">
<button onClick={() => setPendingDelete(g)} className="btn-ghost text-xs text-danger">
<Trash />
</button>
</td>
</tr>
))}
</tbody>
</table></div>
)}
<Confirm
open={confirmClear}
onClose={() => setConfirmClear(false)}
title="Clear all pending gifts"
body={<>Remove every unconsumed gift currently in this player's inbox? Consumed gifts (already opened) are left untouched.</>}
confirmLabel="Clear all"
destructive
onConfirm={clearAll}
/>
<Confirm
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
title={`Delete gift #${pendingDelete?.id}`}
body={<>This removes the gift permanently. If the player had already opened it, the inventory grant from that consume is unaffected.</>}
confirmLabel="Delete"
destructive
onConfirm={delOne}
/>
</div>
);
}
@@ -0,0 +1,36 @@
import { PageHeader } from '../components/PageHeader';
// Maker-pen-anywhere needs server-side integration with CdnController
// (substitute the room blob for flagged players so they get the
// all-permissions PersistedRoomData) AND a PlayerEntity.HasGlobalMakerPen
// column with an EF migration. Wiring is non-trivial enough to keep this
// page as a clearly-labeled placeholder until that lands — saves shipping
// a button that does nothing.
export function Grants() {
return (
<div>
<PageHeader
title="Grant maker pen"
blurb="Flag players who should receive maker-pen permissions in every room they load."
/>
<div className="card !p-6 max-w-2xl space-y-3 text-sm text-ink-300">
<div className="rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
<strong className="text-warn">Not yet wired up.</strong> The endpoint and CDN integration land in a follow-up commit.
</div>
<p>
The plan: add <span className="font-mono text-xs text-ink-100">HasGlobalMakerPen</span> to
<span className="font-mono text-xs text-ink-100"> PlayerEntity</span>, intercept room blob
fetches in <span className="font-mono text-xs text-ink-100">CdnController</span> for flagged players,
and substitute the all-permissions default blob that
<span className="font-mono text-xs text-ink-100"> RoomDataBlobService</span> already produces.
Result: the player loads any room with full Maker Pen / invention spawn permissions, regardless of the
room's owner-defined roles.
</p>
<p className="text-xs text-ink-400">
Until then, use the Player → Grants tab to bestow per-item inventory grants (Maker Pen is granted via
inventory in some flows).
</p>
</div>
</div>
);
}
@@ -0,0 +1,1039 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { BlobReader, TextWriter, ZipReader, type Entry } from '@zip.js/zip.js';
import { ApiError, uploadZipInChunks, type UploadProgress } from '../lib/api';
import { isChromium } from '../lib/browser';
import type { Player } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Upload, Trash, RefreshCw } from '../components/Icons';
// Matches RecNet's real export layout (see comments in
// Controllers/Admin/RoomZipImportController.cs for the schema):
//
// Rooms/Room_<id>_<name>_<ts>/
// RoomDetails.json
// RoomImage.jpg
// Promo/Image/PromoImage_*.jpg
// SubRooms/<SceneName>/
// Subroom.json
// <hash>.room (referenced by Subroom.CurrentSave.DataBlob)
// AudioSampler/<hash>.htr
// Holotar/<hash>.htr
// Image/PVImage_*.jpg
// Inventions/Invention_<id>_<name>_<ts>/
// Invention.json
// InventionDetails.json
// InventionVersion.json
// InventionImage.jpg
// <hash>.inv
//
// We parse the archive client-side purely for a preview/validation
// pass — the same blob is then POSTed to the server, which re-parses
// it authoritatively.
interface RoomDetailsJson {
RoomId?: number;
Name?: string;
FriendlyName?: string;
Description?: string;
ImageName?: string;
CreatedAt?: string;
PublishedAt?: string;
ModifiedAt?: string;
Stats?: { CheerCount?: number; VisitCount?: number; VisitorCount?: number; FavoriteCount?: number };
Tags?: Array<{ Tag?: string; Type?: number }>;
SubRooms?: Array<{ Name?: string; CurrentSave?: { DataBlob?: string }; IsSandbox?: boolean; MaxPlayers?: number; ModifiedAt?: string }>;
}
/// The 2020 Rec Room watch was finalized around September 2020; rooms
/// authored after end-of-2020 commonly reference circuits, props, and
/// UI components added in 2021+ builds that the 2020 binary can't
/// resolve. Importing them still produces a row in the DB but the
/// scene either loads with missing chunks or crashes the watch at
/// load time. We don't refuse the import — just flag in the preview
/// so admins know to expect breakage and can untick those rooms.
const CLIENT_BUILD_CUTOFF = new Date('2020-12-31T00:00:00Z');
interface SubroomJson {
Name?: string;
CurrentSave?: { DataBlob?: string };
IsSandbox?: boolean;
MaxPlayers?: number;
UnitySceneId?: string;
ModifiedAt?: string;
}
interface InventionJson {
Name?: string;
Description?: string;
ImageName?: string;
CurrentVersionNumber?: number;
}
interface PreviewRoom {
folder: string;
name: string;
description: string;
tags: string[];
stats: { visits: number; visitors: number; cheers: number; favorites: number };
subrooms: PreviewSubroom[];
hasImage: boolean;
promoImageCount: number;
/// Latest of CreatedAt / PublishedAt / ModifiedAt / per-subroom
/// ModifiedAt. Used to flag rooms newer than the 2020 client's
/// release window — see CLIENT_BUILD_CUTOFF.
latestModifiedAt: string | null;
postBuildCutoff: boolean;
}
interface PreviewSubroom {
scene: string;
blobFilename: string | null;
blobFound: boolean;
htrAssetCount: number;
pvImageCount: number;
polaroidCount: number;
hasSubroomJson: boolean;
}
interface PreviewInvention {
folder: string;
name: string;
description: string;
hasBlob: boolean;
hasImage: boolean;
versionNumber: number;
}
interface ZipPreview {
rooms: PreviewRoom[];
inventions: PreviewInvention[];
unknownTopLevel: string[];
}
interface AssetBreakdown { referenced: number; newlyImported: number; alreadyInDb: number }
interface ImportResultRoom {
name: string;
ok: boolean;
error?: string;
roomId?: number;
folder?: string;
sceneCount?: number;
entryScene?: string;
entryDetectionSource?: string;
htrAssets?: AssetBreakdown;
pvImages?: AssetBreakdown;
polaroids?: AssetBreakdown;
// Legacy flat counters — keep reading them as a fallback in case
// we point this UI at an older server build.
htrAssetsImported?: number;
pvImagesImported?: number;
missingScenes?: string[] | null;
tags?: string[];
stats?: { cheers: number; favorites: number; visitors: number; visits: number };
scenes?: Array<{ name: string; blobName: string; bytes: number; htrAssets: number; pvImages: number; isSandbox?: boolean; maxPlayers?: number }>;
}
interface ImportResultInvention {
name: string;
ok: boolean;
error?: string;
inventionId?: number;
folder?: string;
blobName?: string;
bytes?: number;
tags?: string[];
}
interface ImportResult {
archiveBytes: number;
roomCount: number;
inventionCount: number;
rooms: ImportResultRoom[];
inventions: ImportResultInvention[];
}
// Stages for the visible upload progress strip. "parsing" lasts until
// the client-side zip preview lands; "uploading" tracks bytes-on-wire;
// "server" is the indeterminate window between upload completion and
// the server response (during which the server is walking the archive,
// normalising blobs, inserting rows). "done" stays terminal until the
// user clears the file.
type ImportStage = 'idle' | 'parsing' | 'uploading' | 'server' | 'done';
export function ImportRoom() {
const chromium = useMemo(() => isChromium(), []);
const { data: players } = useApi<Player[]>('/players?take=500');
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ZipPreview | null>(null);
const [previewErr, setPreviewErr] = useState<string | null>(null);
const [parseProgress, setParseProgress] = useState(0); // 0..100 — number of entries we've inspected
const [ownerId, setOwnerId] = useState<number | null>(null);
// Per-folder selection. Initialised to "everything checked except
// post-2020-cutoff rooms" once the preview lands — admins typically
// want to import everything compatible by default but skip the
// flagged ones until they've vetted them. The Set holds folder paths
// (Rooms/Room_*… or Inventions/Invention_*…).
const [selectedFolders, setSelectedFolders] = useState<Set<string>>(new Set());
// Per-import "round-trip the .room bytes through the 2020 protobuf
// normaliser" toggle. Defaults OFF because the re-encode crashes the
// watch on load; flip on to opt back into the normaliser for a
// specific zip once we're confident its bytes survive the round-trip.
const [normalizeBlobs, setNormalizeBlobs] = useState(false);
const [stage, setStage] = useState<ImportStage>('idle');
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({ loaded: 0, total: 0, percent: 0 });
const [serverStartedAt, setServerStartedAt] = useState<number | null>(null);
const [serverElapsed, setServerElapsed] = useState(0);
const [result, setResult] = useState<ImportResult | null>(null);
const [drag, setDrag] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const toast = useToast();
// Tick a clock while the server is processing so the user knows
// something is happening — large archives can spend 10s+ in
// System.IO.Compression + EF inserts with no wire traffic.
useEffect(() => {
if (stage !== 'server' || serverStartedAt === null) return;
setServerElapsed(0);
const id = window.setInterval(() => setServerElapsed(Math.round((Date.now() - serverStartedAt) / 1000)), 250);
return () => window.clearInterval(id);
}, [stage, serverStartedAt]);
useEffect(() => {
if (!file) { setPreview(null); setPreviewErr(null); setStage('idle'); setSelectedFolders(new Set()); return; }
let cancelled = false;
(async () => {
setStage('parsing');
setPreviewErr(null);
setParseProgress(0);
try {
const p = await parseZipPreview(file, pct => { if (!cancelled) setParseProgress(pct); });
if (!cancelled) {
setPreview(p);
setStage('idle');
// Default selection: every importable room + invention,
// EXCEPT rooms flagged as post-2020-cutoff (admin can tick
// them back on explicitly after acknowledging the warning).
const next = new Set<string>();
for (const r of p.rooms) if (!r.postBuildCutoff) next.add(r.folder);
for (const inv of p.inventions) next.add(inv.folder);
setSelectedFolders(next);
}
} catch (e) {
if (!cancelled) { setPreview(null); setPreviewErr((e as Error).message); setStage('idle'); }
}
})();
return () => { cancelled = true; };
}, [file]);
const toggleFolder = (folder: string) => {
setSelectedFolders(prev => {
const next = new Set(prev);
if (next.has(folder)) next.delete(folder); else next.add(folder);
return next;
});
};
const upload = async () => {
if (!file || !ownerId) return;
setResult(null);
setStage('uploading');
setUploadProgress({ loaded: 0, total: file.size, percent: 0 });
try {
// Chunked upload — Cloudflare's edge caps request bodies at
// 100 MB on free/pro plans, so even our 165 MB sample zip needs
// splitting. The helper slices into ZIP_CHUNK_BYTES (50 MB) pieces
// sent to /zip-upload-chunk, then calls /zip-upload-finalize which
// runs the same import logic the single-shot endpoint does.
// Split the selection into the two server-side filters. Empty
// arrays mean "don't filter" on the server, so if the admin
// unticked everything in a category we send a sentinel ([""])
// that matches no real folder and effectively imports nothing
// from that category.
const selectedRoomFolders = (preview?.rooms ?? [])
.filter(r => selectedFolders.has(r.folder))
.map(r => r.folder);
const selectedInventionFolders = (preview?.inventions ?? [])
.filter(inv => selectedFolders.has(inv.folder))
.map(inv => inv.folder);
const anyRoomSelected = (preview?.rooms.length ?? 0) > 0 && selectedRoomFolders.length === 0;
const anyInventionSelected = (preview?.inventions.length ?? 0) > 0 && selectedInventionFolders.length === 0;
const res = await uploadZipInChunks<ImportResult>(
file,
ownerId,
p => {
setUploadProgress(p);
if (p.total > 0 && p.loaded >= p.total) {
setStage(prev => (prev === 'uploading' ? 'server' : prev));
setServerStartedAt(prev => prev ?? Date.now());
}
},
30 * 60_000,
{
selectedRoomFolders: anyRoomSelected ? ['__NONE__'] : selectedRoomFolders,
selectedInventionFolders: anyInventionSelected ? ['__NONE__'] : selectedInventionFolders,
normalizeBlobs,
},
);
setStage('done');
setResult(res);
const ok = res.rooms.filter(r => r.ok).length;
toast.push(
`Imported ${ok}/${res.rooms.length} rooms · ${res.inventions.filter(i => i.ok).length}/${res.inventions.length} inventions`,
ok === res.rooms.length ? 'success' : 'info',
);
} catch (e) {
setStage('idle');
const msg = e instanceof ApiError ? e.message : (e as Error).message;
toast.push(msg, 'error');
} finally {
setServerStartedAt(null);
}
};
const busy = stage === 'uploading' || stage === 'server';
const previewBusy = stage === 'parsing';
const validRooms = preview?.rooms.filter(r => r.subrooms.some(s => s.blobFound)) ?? [];
const totalScenes = validRooms.reduce((n, r) => n + r.subrooms.length, 0);
const totalHtr = validRooms.reduce((n, r) => n + r.subrooms.reduce((m, s) => m + s.htrAssetCount, 0), 0);
const totalPolaroids = validRooms.reduce((n, r) => n + r.subrooms.reduce((m, s) => m + s.polaroidCount, 0), 0);
return (
<div>
<PageHeader
title="Import room (zip)"
blurb="Drop a RecNet-format export archive. Each Rooms/Room_*/ folder is one room; SubRooms/<Scene>/ holds the scene blob + bundled .htr assets + preview images. The original RoomDetails.json + Subroom.json drive everything — no rec.net round-trip."
actions={<a href="/import-room-legacy" className="btn-ghost text-xs">Use legacy importer →</a>}
/>
{!chromium && (
<div className="card !p-4 mb-4 border-warn/40 bg-warn/10">
<div className="flex items-start gap-3">
<div className="text-warn shrink-0 mt-0.5">⚠</div>
<div>
<div className="text-sm font-semibold text-warn">Chromium-based browser required</div>
<p className="text-xs text-ink-300 mt-1 max-w-prose">
The zip importer uploads multi-hundred-megabyte archives via <code className="font-mono text-[11px]">FormData</code> and uses streaming Blob slicing. Firefox and Safari both choke on this — Firefox's upload progress buffers
stall over ~500 MB, and Safari hits a memory ceiling cloning the form body and aborts mid-upload. Use Chrome, Edge, Brave, Vivaldi, or any other Chromium-based browser for this page. (The rest of the admin works fine everywhere.)
</p>
</div>
</div>
</div>
)}
<div
onDragOver={e => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={e => {
e.preventDefault();
setDrag(false);
if (!chromium) { toast.push('Switch to a Chromium-based browser first', 'error'); return; }
const f = e.dataTransfer.files?.[0];
if (f && f.name.toLowerCase().endsWith('.zip')) {
setFile(f); setResult(null);
} else if (f) {
toast.push('Drop a .zip file', 'error');
}
}}
className={`card !p-8 mb-4 border-2 border-dashed transition-colors ${
drag ? 'border-brand-500 bg-brand-500/5' : 'border-ink-700 bg-ink-900/40'
}`}
>
<div className="flex flex-col items-center justify-center gap-3 text-center">
<Upload className="size-8 text-ink-400" />
<div>
<div className="text-sm font-medium text-ink-100">Drop your archive here, or pick a file</div>
<div className="text-xs text-ink-400 mt-0.5">.zip up to ~2 GB</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip"
disabled={!chromium}
onChange={e => { setFile(e.target.files?.[0] ?? null); setResult(null); }}
className="hidden"
/>
<div className="flex gap-2">
<button onClick={() => fileInputRef.current?.click()} disabled={!chromium} className="btn-secondary text-xs">Browse…</button>
{file && (
<button
onClick={() => { setFile(null); setPreview(null); setResult(null); if (fileInputRef.current) fileInputRef.current.value = ''; }}
className="btn-ghost text-xs text-danger"
>
<Trash /> Clear
</button>
)}
</div>
{file && (
<div className="text-xs text-ink-300 font-mono">
{file.name} · {(file.size / 1024 / 1024).toFixed(1)} MB
</div>
)}
</div>
</div>
{previewBusy && (
<div className="card !p-4 mb-4">
<div className="flex items-center gap-3 text-xs text-ink-300 mb-2">
<RefreshCw className="animate-spin" />
<span>Parsing archive — reading the central directory and walking room manifests…</span>
<span className="ml-auto tabular-nums">{Math.min(100, parseProgress)}%</span>
</div>
<ProgressBar percent={parseProgress} />
</div>
)}
{previewErr && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">
Failed to parse archive: {previewErr}
</div>
)}
{/* Live import status strip — appears while we're uploading the
archive and then while the server processes it. */}
{busy && (
<div className="card !p-4 mb-4">
{stage === 'uploading' && (
<>
<div className="flex items-center gap-3 text-xs text-ink-200 mb-2">
<RefreshCw className="animate-spin text-brand-300" />
<span className="font-medium">Uploading archive…</span>
<span className="ml-auto tabular-nums text-ink-300">
{(uploadProgress.loaded / 1024 / 1024).toFixed(1)} / {(uploadProgress.total / 1024 / 1024).toFixed(1)} MB
{' · '}{uploadProgress.percent}%
</span>
</div>
<ProgressBar percent={uploadProgress.percent} />
</>
)}
{stage === 'server' && (
<>
<div className="flex items-center gap-3 text-xs text-ink-200 mb-2">
<RefreshCw className="animate-spin text-brand-300" />
<span className="font-medium">Server is processing the archive…</span>
<span className="ml-auto tabular-nums text-ink-300">{serverElapsed}s elapsed</span>
</div>
<p className="text-[11px] text-ink-400 mb-2">
Unpacking, normalising every <code className="font-mono">.room</code> blob, inserting room + scene rows, persisting bundled <code className="font-mono">.htr</code> assets and preview images.
Large archives can take 30s+; we'll show the per-room result table when it finishes.
</p>
<ProgressBar indeterminate />
</>
)}
</div>
)}
{preview && (
<div className="card !p-5 mb-4">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-4">
<Stat label="Rooms" value={preview.rooms.length} />
<Stat label="Scenes" value={totalScenes} />
<Stat label=".htr assets" value={totalHtr} />
<Stat label="Polaroids" value={totalPolaroids} />
<Stat label="Inventions" value={preview.inventions.length} />
</div>
{preview.rooms.length > 0 && (
<details open className="mb-3">
<summary className="cursor-pointer text-sm font-semibold text-ink-100 mb-2 flex items-center gap-3">
<span>Rooms ({preview.rooms.filter(r => selectedFolders.has(r.folder)).length}/{preview.rooms.length} selected)</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
const allOn = preview.rooms.every(r => selectedFolders.has(r.folder));
setSelectedFolders(prev => {
const next = new Set(prev);
for (const r of preview.rooms) {
if (allOn) next.delete(r.folder); else next.add(r.folder);
}
return next;
});
}}
className="btn-ghost text-[10px] uppercase tracking-widest"
>
{preview.rooms.every(r => selectedFolders.has(r.folder)) ? 'Deselect all' : 'Select all'}
</button>
</summary>
<div className="space-y-2">
{preview.rooms.map(r => (
<div
key={r.folder}
className={`rounded-lg border p-3 transition-colors ${
selectedFolders.has(r.folder)
? 'border-ink-800'
: 'border-ink-800/50 bg-ink-900/30 opacity-70'
}`}
>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedFolders.has(r.folder)}
onChange={() => toggleFolder(r.folder)}
className="size-4 accent-brand-500"
/>
<span className="font-medium text-ink-50">{r.name}</span>
</label>
<span className="text-xs text-ink-500 font-mono">{r.folder.split('/').pop()}</span>
{!r.hasImage && <span className="badge-junior">no image</span>}
{r.postBuildCutoff && (
<span
className="badge-banned"
title={`Latest timestamp ${r.latestModifiedAt} is after the 2020 client cutoff (${CLIENT_BUILD_CUTOFF.toISOString().slice(0, 10)}). Scene may use circuits/props the watch doesn't ship.`}
>
may be incompatible
</span>
)}
{r.latestModifiedAt && (
<span className="text-[10px] text-ink-500 font-mono" title={r.latestModifiedAt}>
{r.latestModifiedAt.slice(0, 10)}
</span>
)}
</div>
{r.description && <p className="text-xs text-ink-400 mt-1 line-clamp-2">{r.description}</p>}
<div className="mt-2 flex flex-wrap gap-1.5 text-xs">
{r.tags.map(t => <span key={t} className="badge-neutral">{t}</span>)}
</div>
<div className="mt-2 grid grid-cols-2 md:grid-cols-5 gap-2 text-xs text-ink-300">
<Mini label="Scenes" value={r.subrooms.length} />
<Mini label="HTR" value={r.subrooms.reduce((n, s) => n + s.htrAssetCount, 0)} />
<Mini label="PV imgs" value={r.subrooms.reduce((n, s) => n + s.pvImageCount, 0)} />
<Mini label="Polaroids" value={r.subrooms.reduce((n, s) => n + s.polaroidCount, 0)} />
<Mini label="Promo" value={r.promoImageCount} />
</div>
{r.subrooms.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-ink-400">Scene breakdown</summary>
<table className="w-full text-xs mt-1.5">
<thead className="text-[10px] uppercase tracking-wider text-ink-500">
<tr>
<th className="text-left font-medium pb-1">Scene</th>
<th className="text-left font-medium pb-1">Blob</th>
<th className="text-right font-medium pb-1">HTR</th>
<th className="text-right font-medium pb-1">PV</th>
<th className="text-right font-medium pb-1">Pol</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{r.subrooms.map(s => (
<tr key={s.scene}>
<td className="py-1 text-ink-100">{s.scene}</td>
<td className="py-1 font-mono text-[11px] text-ink-300">
{s.blobFilename ?? <span className="text-danger">no DataBlob in Subroom.json</span>}
{s.blobFilename && !s.blobFound && <span className="text-danger ml-1">(missing!)</span>}
{!s.hasSubroomJson && <span className="text-danger ml-1">(no Subroom.json)</span>}
</td>
<td className="py-1 text-right text-ink-300 tabular-nums">{s.htrAssetCount}</td>
<td className="py-1 text-right text-ink-300 tabular-nums">{s.pvImageCount}</td>
<td className="py-1 text-right text-ink-300 tabular-nums">{s.polaroidCount}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
</div>
))}
</div>
</details>
)}
{preview.inventions.length > 0 && (
<details className="mb-3">
<summary className="cursor-pointer text-sm font-semibold text-ink-100 flex items-center gap-3">
<span>Inventions ({preview.inventions.filter(inv => selectedFolders.has(inv.folder)).length}/{preview.inventions.length} selected)</span>
<button
type="button"
onClick={(e) => {
// Mirror the Rooms section toggle — "select all"
// when at least one is unchecked, "deselect all"
// once every invention is on. Stops the <details>
// collapsing on click via preventDefault.
e.preventDefault();
const allOn = preview.inventions.every(inv => selectedFolders.has(inv.folder));
setSelectedFolders(prev => {
const next = new Set(prev);
for (const inv of preview.inventions) {
if (allOn) next.delete(inv.folder); else next.add(inv.folder);
}
return next;
});
}}
className="btn-ghost text-[10px] uppercase tracking-widest"
>
{preview.inventions.every(inv => selectedFolders.has(inv.folder)) ? 'Deselect all' : 'Select all'}
</button>
</summary>
<table className="w-full text-sm mt-2">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
<tr>
<th className="w-8 pb-1" />
<th className="text-left font-medium pb-1">Name</th>
<th className="text-left font-medium pb-1">Folder</th>
<th className="text-right font-medium pb-1">Version</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{preview.inventions.map(inv => (
<tr key={inv.folder} className={selectedFolders.has(inv.folder) ? '' : 'opacity-60'}>
<td className="py-1.5">
<input
type="checkbox"
checked={selectedFolders.has(inv.folder)}
onChange={() => toggleFolder(inv.folder)}
className="size-4 accent-brand-500"
/>
</td>
<td className="py-1.5 text-ink-100">{inv.name}</td>
<td className="py-1.5 font-mono text-xs text-ink-400">{inv.folder.split('/').pop()}</td>
<td className="py-1.5 text-right text-ink-200 tabular-nums">{inv.versionNumber}</td>
<td className="py-1.5">
<div className="flex gap-1">
{!inv.hasBlob && <span className="badge-banned">no blob</span>}
{!inv.hasImage && <span className="badge-junior">no image</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</details>
)}
{preview.unknownTopLevel.length > 0 && (
<details>
<summary className="cursor-pointer text-xs text-ink-500">{preview.unknownTopLevel.length} file(s) outside the schema</summary>
<ul className="text-[11px] text-ink-500 font-mono pl-3 mt-1 max-h-32 overflow-y-auto">
{preview.unknownTopLevel.map(f => <li key={f}>{f}</li>)}
</ul>
</details>
)}
</div>
)}
{preview && (preview.rooms.length > 0 || preview.inventions.length > 0) && (
<div className="card !p-4 mb-4 space-y-3">
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 flex-1 min-w-[260px]">
<span className="label">Owner (becomes the creator of every imported room + invention)</span>
<select value={ownerId ?? ''} onChange={e => setOwnerId(e.target.value ? parseInt(e.target.value) : null)} disabled={!chromium} className="input">
<option value="">— pick an owner —</option>
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</label>
<button onClick={upload} disabled={busy || !ownerId || !chromium} className="btn-primary text-xs">
{stage === 'uploading' ? `Uploading… ${uploadProgress.percent}%`
: stage === 'server' ? `Server processing… ${serverElapsed}s`
: (() => {
const selectedRoomsCount = preview.rooms.filter(r => selectedFolders.has(r.folder)).length;
const selectedInvCount = preview.inventions.filter(inv => selectedFolders.has(inv.folder)).length;
return `Import ${selectedRoomsCount} room${selectedRoomsCount === 1 ? '' : 's'} · ${selectedInvCount} invention${selectedInvCount === 1 ? '' : 's'}`;
})()}
</button>
</div>
{/* Per-import blob-normaliser toggle. Visible as a checkbox so
the admin makes the call PER ZIP — defaults off because the
2020 protobuf re-encode currently crashes the watch on load.
The diagnostic Normalize() call still runs server-side either
way and its parse-OK / parse-FAIL log line stays in import
telemetry. */}
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={normalizeBlobs}
onChange={e => setNormalizeBlobs(e.target.checked)}
disabled={busy}
className="size-4 mt-0.5 accent-brand-500"
/>
<span className="text-xs leading-snug">
<span className="text-ink-100">Re-encode .room blobs through the 2020 protobuf schema</span>
<span className="block text-[11px] text-ink-500 mt-0.5">
Strips non-canonical encodings from modern Rec Room exports so the 2020 watch's
stricter parser accepts them. Off by default — the current 2020 schema bindings
are incomplete and the re-encode tends to crash the watch on dorm/room load.
Leave OFF unless you've verified the round-trip works for this specific zip.
</span>
</span>
</label>
</div>
)}
{result && (
<div className="card !p-5">
<h2 className="text-sm font-semibold text-ink-50 mb-3">Import result</h2>
<div className="grid grid-cols-3 gap-3 mb-4 text-sm">
<Stat label="Archive size" value={`${(result.archiveBytes / 1024 / 1024).toFixed(1)} MB`} />
<Stat label="Rooms" value={result.roomCount} />
<Stat label="Inventions" value={result.inventions.length} />
</div>
{result.rooms.length > 0 && (
<>
<h3 className="text-xs font-semibold uppercase tracking-widest text-ink-300 mb-1.5">Rooms</h3>
<table className="w-full text-sm mb-4">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
<tr>
<th className="text-left font-medium pb-1">Room</th>
<th className="text-left font-medium pb-1">Result</th>
<th className="text-left font-medium pb-1">Entry</th>
<th className="text-right font-medium pb-1">Scenes</th>
<th className="text-left font-medium pb-1">HTR</th>
<th className="text-left font-medium pb-1">PV</th>
<th className="text-left font-medium pb-1">Polaroids</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{result.rooms.map(r => (
<tr key={r.folder ?? r.name}>
<td className="py-1.5 text-ink-100">{r.name}{r.roomId !== undefined && <span className="text-ink-500 text-xs"> · #{r.roomId}</span>}</td>
<td className="py-1.5">
{r.ok
? <span className="badge-online">imported</span>
: <span className="badge-banned" title={r.error}>failed{r.error ? ` — ${r.error}` : ''}</span>}
</td>
<td className="py-1.5 text-xs text-ink-300">
{r.entryScene ?? '—'}
{r.entryDetectionSource && <span className="text-ink-500"> ({r.entryDetectionSource})</span>}
</td>
<td className="py-1.5 text-right text-ink-200 tabular-nums">{r.sceneCount ?? '—'}</td>
<td className="py-1.5"><AssetCell breakdown={r.htrAssets} legacy={r.htrAssetsImported} /></td>
<td className="py-1.5"><AssetCell breakdown={r.pvImages} legacy={r.pvImagesImported} /></td>
<td className="py-1.5"><AssetCell breakdown={r.polaroids} /></td>
</tr>
))}
</tbody>
</table>
</>
)}
{result.inventions.length > 0 && (
<>
<h3 className="text-xs font-semibold uppercase tracking-widest text-ink-300 mb-1.5">Inventions</h3>
<table className="w-full text-sm">
<tbody className="divide-y divide-ink-800">
{result.inventions.map(i => (
<tr key={i.folder ?? i.name}>
<td className="py-1.5 text-ink-100">{i.name}</td>
<td className="py-1.5">
{i.ok
? <span className="badge-online">invention #{i.inventionId}</span>
: <span className="badge-banned">{i.error ?? 'failed'}</span>}
</td>
<td className="py-1.5 text-xs text-ink-400">{i.blobName}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)}
</div>
);
}
function Stat({ label, value }: { label: string; value: number | string }) {
return (
<div className="card !p-3">
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className="mt-0.5 text-lg font-semibold tabular-nums text-ink-50">
{typeof value === 'number' ? value.toLocaleString() : value}
</div>
</div>
);
}
// "16 new · 44 already · 60 total" so admins can see at a glance
// whether HTR/PV blobs are present even after a re-import where most
// were already in the DB from a prior run (purge-custom-rooms keeps
// shared assets by design).
function AssetCell({ breakdown, legacy }: { breakdown?: AssetBreakdown; legacy?: number }) {
if (!breakdown) {
return <span className="text-ink-300 tabular-nums">{legacy ?? 0}</span>;
}
const present = breakdown.newlyImported + breakdown.alreadyInDb;
if (breakdown.referenced === 0) return <span className="text-ink-500">—</span>;
const allPresent = present >= breakdown.referenced;
return (
<div className="flex items-center gap-1.5 text-xs">
<span className={`tabular-nums font-medium ${allPresent ? 'text-success' : 'text-warn'}`}>
{present}/{breakdown.referenced}
</span>
<span className="text-ink-500">
({breakdown.newlyImported} new
{breakdown.alreadyInDb > 0 ? ` · ${breakdown.alreadyInDb} kept` : ''})
</span>
</div>
);
}
function Mini({ label, value }: { label: string; value: number }) {
return (
<div className="rounded border border-ink-800 px-2 py-1">
<div className="text-[10px] uppercase tracking-widest text-ink-500">{label}</div>
<div className="text-sm text-ink-100 tabular-nums">{value}</div>
</div>
);
}
// Determinate bar when percent is given (0-100); indeterminate uses a
// keyframed sliding strip so the user can see something's happening
// even though we don't know how long the server will take.
function ProgressBar({ percent, indeterminate }: { percent?: number; indeterminate?: boolean }) {
if (indeterminate) {
return (
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-ink-800">
<div className="absolute inset-y-0 left-0 w-1/3 rounded-full bg-brand-500 animate-[progress-slide_1.4s_ease-in-out_infinite]" />
<style>{`@keyframes progress-slide { 0% { transform: translateX(-100%); } 50% { transform: translateX(150%); } 100% { transform: translateX(350%); } }`}</style>
</div>
);
}
const clamped = Math.max(0, Math.min(100, percent ?? 0));
return (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-800">
<div className="h-full rounded-full bg-brand-500 transition-[width] duration-200" style={{ width: `${clamped}%` }} />
</div>
);
}
// ── Zip parsing ───────────────────────────────────────────────────────
async function parseZipPreview(file: File, onProgress?: (percent: number) => void): Promise<ZipPreview> {
const reader = new ZipReader(new BlobReader(file));
let entries: Entry[];
try {
entries = await reader.getEntries();
} finally {
await reader.close();
}
// ZipReader.getEntries reads the central directory — that's the
// slow bit. Once we have the entries array, the rest is in-memory
// map building + small JSON reads for the manifest files. We tick
// progress as we walk; manifest JSONs read after that bump us to
// 100%.
onProgress?.(20);
// Build a path-indexed view first; the rest of the parse runs against
// this map, never touching the zip stream again.
const allPaths = new Set<string>();
const entryByPath = new Map<string, Entry>();
for (const e of entries) {
if (e.directory) continue;
const p = e.filename.replace(/\\/g, '/').replace(/^\/+/, '');
allPaths.add(p);
entryByPath.set(p, e);
}
// Count manifests up front so we can advance progress smoothly
// as we read each one — the central-directory parse and the
// manifest reads dominate; the rest is O(entries) string work.
//
// CRITICAL: the counter has to match what the iteration loops below
// actually call `bump()` on, or progress overflows past 100%. Invention
// manifests are accepted at TWO locations (top-level `Inventions/...`
// AND per-room `Rooms/Room_X/Inventions/...`); originally we only
// counted the top-level ones, so a zip with many per-room inventions
// hit 374% on the parse bar. Match the same predicates here.
const roomManifestPaths: string[] = [];
const inventionManifestPaths: string[] = [];
for (const p of allPaths) {
if (p.startsWith('Rooms/') && p.endsWith('/RoomDetails.json')) {
roomManifestPaths.push(p);
} else if (p.endsWith('/Invention.json')
&& (p.startsWith('Inventions/') || p.includes('/Inventions/'))) {
inventionManifestPaths.push(p);
}
}
const totalManifests = roomManifestPaths.length + inventionManifestPaths.length;
let processed = 0;
const bump = () => {
processed++;
// Reserve 20% for the central-dir parse already done, leave 10%
// for the final cleanup, so manifest reads occupy 20..90%. Clamp
// defensively in case the counters drift again — better to peg the
// bar at 90% than show "374%" to the user.
const raw = totalManifests > 0 ? 20 + (processed / totalManifests) * 70 : 90;
onProgress?.(Math.min(90, Math.round(raw)));
};
// Per-room walk. Anchor on Rooms/<folder>/RoomDetails.json.
const rooms: PreviewRoom[] = [];
for (const path of allPaths) {
if (!path.startsWith('Rooms/') || !path.endsWith('/RoomDetails.json')) continue;
const roomFolder = path.slice(0, -'/RoomDetails.json'.length);
const detailsEntry = entryByPath.get(path);
let details: RoomDetailsJson = {};
if (detailsEntry) {
try { details = JSON.parse(await readText(detailsEntry)); } catch { /* leave as default */ }
}
// Scene folders under SubRooms/
const sceneRoot = `${roomFolder}/SubRooms/`;
const sceneNames = new Set<string>();
for (const p of allPaths) {
if (!p.startsWith(sceneRoot)) continue;
const rest = p.slice(sceneRoot.length);
const slash = rest.indexOf('/');
if (slash <= 0) continue;
sceneNames.add(rest.slice(0, slash));
}
const subrooms: PreviewSubroom[] = [];
for (const scene of sceneNames) {
const sceneFolder = `${sceneRoot}${scene}`;
const subroomJsonPath = `${sceneFolder}/Subroom.json`;
let subroomJson: SubroomJson = {};
const hasSubroomJson = entryByPath.has(subroomJsonPath);
if (hasSubroomJson) {
try { subroomJson = JSON.parse(await readText(entryByPath.get(subroomJsonPath)!)); } catch { /* default */ }
}
const blobFilename = subroomJson.CurrentSave?.DataBlob ?? null;
const blobPath = blobFilename ? `${sceneFolder}/${blobFilename}` : null;
const blobFound = blobPath ? entryByPath.has(blobPath) : false;
let htrAssetCount = 0;
let pvImageCount = 0;
let polaroidCount = 0;
const imgPrefix = `${sceneFolder}/Image/`;
const polPrefix = `${sceneFolder}/Polaroids/`;
// Server-side mirrors this: a `PVImage_<hash>.ext` file in
// Image/ counts as BOTH a PV image (its own filename) and a
// polaroid (with the prefix stripped). The watch fetches both
// URLs, so the importer writes the bytes twice.
for (const p of allPaths) {
if (p.startsWith(`${sceneFolder}/AudioSampler/`) && p.endsWith('.htr')) htrAssetCount++;
else if (p.startsWith(`${sceneFolder}/Holotar/`) && p.endsWith('.htr')) htrAssetCount++;
else if (p.startsWith(imgPrefix)) {
pvImageCount++;
const file = p.slice(imgPrefix.length);
if (file.toLowerCase().startsWith('pvimage_')) polaroidCount++;
}
else if (p.startsWith(polPrefix)) polaroidCount++;
}
subrooms.push({ scene, blobFilename, blobFound, htrAssetCount, pvImageCount, polaroidCount, hasSubroomJson });
}
// Sort by RoomDetails.SubRooms[] order if present so the preview
// shows the entry-scene first.
if (details.SubRooms && details.SubRooms.length > 0) {
const manifestOrder = details.SubRooms.map(s => s.Name ?? '').filter(Boolean);
subrooms.sort((a, b) => {
const ai = manifestOrder.indexOf(a.scene);
const bi = manifestOrder.indexOf(b.scene);
if (ai === -1 && bi === -1) return a.scene.localeCompare(b.scene);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
} else {
subrooms.sort((a, b) => a.scene.localeCompare(b.scene));
}
// Image / promo discovery
const hasImage = !!details.ImageName && entryByPath.has(`${roomFolder}/${details.ImageName}`)
|| Array.from(allPaths).some(p => p.startsWith(`${roomFolder}/RoomImage.`));
let promoImageCount = 0;
for (const p of allPaths) if (p.startsWith(`${roomFolder}/Promo/Image/`)) promoImageCount++;
// Pick the most-recent timestamp across the room's own CreatedAt /
// PublishedAt / ModifiedAt and each subroom's ModifiedAt — the
// most aggressive signal of "made on a build newer than 2020".
const candidateDates: number[] = [];
for (const raw of [details.CreatedAt, details.PublishedAt, details.ModifiedAt]) {
if (raw) {
const t = Date.parse(raw);
if (!Number.isNaN(t)) candidateDates.push(t);
}
}
// We need to re-read the subroom jsons we already parsed above
// for their ModifiedAt fields. Cheap second pass — preview only
// parses small JSONs.
for (const scene of sceneNames) {
const subroomJsonPath = `${sceneRoot}${scene}/Subroom.json`;
if (!entryByPath.has(subroomJsonPath)) continue;
try {
const sr = JSON.parse(await readText(entryByPath.get(subroomJsonPath)!)) as SubroomJson;
if (sr.ModifiedAt) {
const t = Date.parse(sr.ModifiedAt);
if (!Number.isNaN(t)) candidateDates.push(t);
}
} catch { /* skip */ }
}
const latestMs = candidateDates.length > 0 ? Math.max(...candidateDates) : null;
const latestModifiedAt = latestMs !== null ? new Date(latestMs).toISOString() : null;
const postBuildCutoff = latestMs !== null && latestMs > CLIENT_BUILD_CUTOFF.getTime();
rooms.push({
folder: roomFolder,
name: details.Name ?? details.FriendlyName ?? roomFolder.split('/').pop()!,
description: details.Description ?? '',
tags: (details.Tags ?? []).map(t => t.Tag).filter((t): t is string => !!t),
stats: {
visits: details.Stats?.VisitCount ?? 0,
visitors: details.Stats?.VisitorCount ?? 0,
cheers: details.Stats?.CheerCount ?? 0,
favorites: details.Stats?.FavoriteCount ?? 0,
},
subrooms,
hasImage,
promoImageCount,
latestModifiedAt,
postBuildCutoff,
});
bump();
}
// Inventions — top-level `Inventions/` OR per-room `Rooms/<r>/Inventions/`.
// The exporter uses either layout depending on the room (rooms with
// their own attached inventions ship them inside the room folder).
const inventions: PreviewInvention[] = [];
for (const path of allPaths) {
if (!path.endsWith('/Invention.json')) continue;
if (!path.startsWith('Inventions/') && !path.includes('/Inventions/')) continue;
const folder = path.slice(0, -'/Invention.json'.length);
let meta: InventionJson = {};
try { meta = JSON.parse(await readText(entryByPath.get(path)!)); } catch { /* default */ }
const hasBlob = Array.from(allPaths).some(p => p.startsWith(`${folder}/`) && p.endsWith('.inv'));
const hasImage = (!!meta.ImageName && entryByPath.has(`${folder}/${meta.ImageName}`))
|| Array.from(allPaths).some(p => p.startsWith(`${folder}/InventionImage.`));
inventions.push({
folder,
name: meta.Name ?? folder.split('/').pop()!,
description: meta.Description ?? '',
hasBlob,
hasImage,
versionNumber: meta.CurrentVersionNumber ?? 1,
});
bump();
}
// Anything not under Rooms/ or top-level Inventions/ is flagged.
// Per-room Inventions/ paths are valid (live under Rooms/) so they
// don't trigger here.
const unknownTopLevel: string[] = [];
for (const p of allPaths) {
if (!p.startsWith('Rooms/') && !p.startsWith('Inventions/')) unknownTopLevel.push(p);
}
unknownTopLevel.sort();
rooms.sort((a, b) => a.name.localeCompare(b.name));
inventions.sort((a, b) => a.name.localeCompare(b.name));
onProgress?.(100);
return { rooms, inventions, unknownTopLevel };
}
// Entry is a union of FileEntry | DirectoryEntry; only FileEntry has
// .getData. We already filter out directories before reaching this,
// but TS can't carry that info through the Map, so narrow explicitly.
async function readText(entry: Entry): Promise<string> {
const file = entry as Entry & { getData?: (writer: TextWriter) => Promise<string> };
if (!file.getData) throw new Error(`zip entry "${entry.filename}" has no data (directory entry)`);
return file.getData(new TextWriter());
}
@@ -0,0 +1,242 @@
import { useMemo, useRef, useState } from 'react';
import { api, ApiError } from '../lib/api';
import type { Player, Room } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
// Extracts the file's parent folder name from a webkitRelativePath
// like "YarrHarrHeist/Lobby/abc.room" → "Lobby". Mirrors the C# helper
// in AdminController.ImportRoom so the preview matches what the server
// will actually use.
function extractScene(relPath: string): string {
const parts = relPath.replace(/\\/g, '/').split('/').filter(Boolean);
return parts.length >= 2 ? parts[parts.length - 2] : '';
}
interface FilePick {
file: File;
scene: string;
}
export function LegacyImportRoom() {
const { data: players } = useApi<Player[]>('/players?take=500');
const { data: rooms } = useApi<Room[]>('/rooms');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [entryScene, setEntryScene] = useState('');
const [ownerId, setOwnerId] = useState<number | null>(null);
const [picks, setPicks] = useState<FilePick[]>([]);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
const folderRef = useRef<HTMLInputElement>(null);
const filesRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const distinctScenes = useMemo(() => Array.from(new Set(picks.map(p => p.scene))).filter(Boolean), [picks]);
const onFolder = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
setPicks(files
.filter(f => f.name.endsWith('.room'))
.map(f => {
// webkitRelativePath is in lib.dom.d.ts as a non-empty string when
// the file was picked via <input webkitdirectory>; otherwise "".
const rel = f.webkitRelativePath ?? '';
return { file: f, scene: extractScene(rel) || extractScene(f.name) };
})
);
};
const onFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
setPicks(prev => [
...prev,
...files.filter(f => f.name.endsWith('.room'))
.map(f => ({ file: f, scene: '' })),
]);
};
const updateScene = (idx: number, scene: string) => {
setPicks(prev => prev.map((p, i) => i === idx ? { ...p, scene } : p));
};
const removeFile = (idx: number) => {
setPicks(prev => prev.filter((_, i) => i !== idx));
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !ownerId || picks.length === 0) return;
if (picks.some(p => !p.scene.trim())) {
toast.push('Every file needs a scene folder name', 'error');
return;
}
setBusy(true);
setResult(null);
const fd = new FormData();
fd.append('name', name.trim());
fd.append('description', description.trim());
fd.append('entryScene', entryScene.trim());
fd.append('creatorPlayerId', String(ownerId));
for (const p of picks) {
fd.append('files', p.file, p.file.name);
fd.append('scenePaths', p.scene);
}
try {
const res = await api<{
roomId: number; roomName: string; sceneCount: number; entryScene: string;
normalizedSceneCount: number; htrMirrorStarted: boolean;
}>('/rooms/import', { method: 'POST', formData: fd, timeoutMs: 600_000 });
setResult(`Imported room #${res.roomId} "${res.roomName}" — ${res.sceneCount} scene(s), entry: ${res.entryScene}. .htr mirror running in background.`);
toast.push('Room imported', 'success');
setName(''); setDescription(''); setEntryScene(''); setPicks([]);
if (folderRef.current) folderRef.current.value = '';
if (filesRef.current) filesRef.current.value = '';
} catch (e) {
const msg = e instanceof ApiError ? e.message : (e as Error).message;
setResult(`Failed: ${msg}`);
toast.push(msg, 'error');
} finally {
setBusy(false);
}
};
// ── .htr re-mirror ────────────────────────────────────────────────
const [mirrorRoomId, setMirrorRoomId] = useState<number | ''>('');
const [mirrorResult, setMirrorResult] = useState<string | null>(null);
const [mirrorBusy, setMirrorBusy] = useState(false);
const runMirror = async () => {
if (!mirrorRoomId) return;
setMirrorBusy(true);
setMirrorResult(null);
try {
const res = await api<{ uniqueRefs: number; downloaded: number; alreadyMirrored: number }>(
`/rooms/${mirrorRoomId}/mirror-htr`, { method: 'POST', timeoutMs: 600_000 }
);
setMirrorResult(`Scanned ${res.uniqueRefs} refs · ${res.downloaded} downloaded · ${res.alreadyMirrored} already mirrored.`);
toast.push('Mirror complete', 'success');
} catch (e) {
const msg = (e as Error).message;
setMirrorResult(msg);
toast.push(msg, 'error');
} finally {
setMirrorBusy(false);
}
};
return (
<div>
<PageHeader
title="Import room (legacy)"
blurb="Original folder-based importer. The new zip-based importer at /import-room handles the same workflow with less manual fiddling; this page stays around for tooling that already targets the legacy multi-file endpoint."
/>
<form onSubmit={submit} className="card !p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Room name">
<input value={name} onChange={e => setName(e.target.value)} required className="input" placeholder="YarrHarrHeist" />
</Field>
<Field label="Entry scene (optional, defaults to Lobby or alphabetical first)">
<input value={entryScene} onChange={e => setEntryScene(e.target.value)} className="input" placeholder="Lobby" />
</Field>
</div>
<Field label="Description">
<input value={description} onChange={e => setDescription(e.target.value)} className="input" placeholder="Imported from archive" />
</Field>
<Field label="Owner (account that becomes the room's creator)">
<select value={ownerId ?? ''} onChange={e => setOwnerId(e.target.value ? parseInt(e.target.value) : null)} required className="input">
<option value="">— pick an owner —</option>
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</Field>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Pick a folder (preserves scene structure)">
<input
ref={folderRef}
type="file"
multiple
// @ts-expect-error — webkitdirectory is non-standard but supported by Chromium + Firefox
webkitdirectory=""
directory=""
onChange={onFolder}
className="input file:mr-2 file:rounded-md file:border-0 file:bg-ink-700 file:px-2.5 file:py-1 file:text-xs file:text-ink-100 file:cursor-pointer"
/>
</Field>
<Field label="…or pick individual .room files (set scene manually)">
<input
ref={filesRef}
type="file"
multiple
accept=".room"
onChange={onFiles}
className="input file:mr-2 file:rounded-md file:border-0 file:bg-ink-700 file:px-2.5 file:py-1 file:text-xs file:text-ink-100 file:cursor-pointer"
/>
</Field>
</div>
{picks.length > 0 && (
<div className="card !p-3">
<div className="text-xs text-ink-400 mb-2">
{picks.length} file{picks.length === 1 ? '' : 's'} · {distinctScenes.length} scene{distinctScenes.length === 1 ? '' : 's'} ({distinctScenes.join(', ') || 'none'})
</div>
<table className="w-full text-xs">
<thead className="text-ink-400">
<tr><th className="text-left pb-1">Scene</th><th className="text-left pb-1">File</th><th className="text-right pb-1">Bytes</th><th /></tr>
</thead>
<tbody className="divide-y divide-ink-800">
{picks.map((p, i) => (
<tr key={i}>
<td className="py-1.5"><input value={p.scene} onChange={e => updateScene(i, e.target.value)} className="input !py-1 text-xs" /></td>
<td className="py-1.5 font-mono text-ink-300">{p.file.name}</td>
<td className="py-1.5 text-right text-ink-400 tabular-nums">{p.file.size.toLocaleString()}</td>
<td className="py-1.5 text-right"><button type="button" onClick={() => removeFile(i)} className="btn-ghost text-xs">×</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex justify-end">
<button disabled={busy || !name.trim() || !ownerId || picks.length === 0} className="btn-primary text-xs">
{busy ? 'Uploading…' : `Import (${picks.length} files)`}
</button>
</div>
{result && <div className="card !p-3 text-xs text-ink-200 whitespace-pre-wrap">{result}</div>}
</form>
<div className="mt-6">
<h2 className="text-lg font-semibold text-ink-50 mb-1">Re-mirror .htr assets</h2>
<p className="text-sm text-ink-400 mb-3">
Synchronously rescan an existing room's blobs and download any Holotar / AudioSampler <code className="font-mono text-xs">.htr</code> refs not yet in <code className="font-mono text-xs">RoomDataBlobs</code>.
</p>
<div className="card !p-4 flex flex-wrap items-end gap-2">
<Field label="Room">
<select value={mirrorRoomId} onChange={e => setMirrorRoomId(e.target.value ? parseInt(e.target.value) : '')} className="input min-w-[280px]">
<option value="">— pick a room —</option>
{(rooms ?? []).map(r => <option key={r.id} value={r.id}>{r.name} · #{r.id} · {r.blobCount} blobs</option>)}
</select>
</Field>
<button onClick={runMirror} disabled={mirrorBusy || !mirrorRoomId} className="btn-secondary text-xs">
{mirrorBusy ? 'Mirroring…' : 'Run mirror'}
</button>
{mirrorResult && <div className="text-xs text-ink-300 ml-2">{mirrorResult}</div>}
</div>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="label">{label}</span>
{children}
</label>
);
}
@@ -0,0 +1,303 @@
import { useEffect, useRef, useState } from 'react';
import { api, get } from '../lib/api';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Plus, Trash } from '../components/Icons';
interface LoadingScreenTip {
id?: number;
title: string;
message: string;
imageName: string;
context: number;
platformMask: number;
roomNamesCsv: string;
sortOrder: number;
isActive: boolean;
}
function blankTip(): LoadingScreenTip {
return {
title: '',
message: '',
imageName: '',
context: 0,
platformMask: -1,
roomNamesCsv: '',
sortOrder: 0,
isActive: true,
};
}
export function LoadingTips() {
const [tips, setTips] = useState<LoadingScreenTip[] | null>(null);
const [err, setErr] = useState<string | null>(null);
const toast = useToast();
useEffect(() => {
get<LoadingScreenTip[]>('/loadingscreentips')
.then(setTips)
.catch(e => setErr((e as Error).message));
}, []);
const update = (idx: number, next: LoadingScreenTip) => {
if (!tips) return;
const copy = tips.slice();
copy[idx] = next;
setTips(copy);
};
const removeLocal = (idx: number) => {
if (!tips) return;
setTips(tips.filter((_, i) => i !== idx));
};
const addNew = async () => {
try {
const created = await api<LoadingScreenTip>('/loadingscreentips', {
method: 'POST',
body: blankTip(),
});
setTips([...(tips ?? []), created]);
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const save = async (idx: number) => {
if (!tips) return;
const t = tips[idx];
try {
if (t.id) {
const saved = await api<LoadingScreenTip>(`/loadingscreentips/${t.id}`, {
method: 'PUT',
body: t,
});
update(idx, saved);
} else {
const saved = await api<LoadingScreenTip>('/loadingscreentips', {
method: 'POST',
body: t,
});
update(idx, saved);
}
toast.push('Saved', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const remove = async (idx: number) => {
if (!tips) return;
const t = tips[idx];
if (!confirm(`Delete tip "${t.title || '(untitled)'}"?`)) return;
try {
if (t.id) await api(`/loadingscreentips/${t.id}`, { method: 'DELETE' });
removeLocal(idx);
toast.push('Deleted', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
return (
<div>
<PageHeader
title="Loading screen tips"
blurb="The list the watch fetches from cdn.localhost/config/LoadingScreenTipData on every dorm load. Edit, upload an image, or scope a tip to specific rooms — saves are live immediately."
actions={<button onClick={addNew} className="btn-primary text-xs"><Plus /> Add tip</button>}
/>
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">
{err}
</div>
)}
{tips === null && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading tips…</div>
)}
{tips && tips.length === 0 && (
<div className="card !p-6 text-center text-xs text-ink-400">
No tips yet — add one with the button above.
</div>
)}
{tips && (
<div className="space-y-3">
{tips.map((t, i) => (
<TipEditor
key={t.id ?? `new-${i}`}
tip={t}
onChange={next => update(i, next)}
onSave={() => save(i)}
onRemove={() => remove(i)}
/>
))}
</div>
)}
</div>
);
}
function TipEditor({
tip,
onChange,
onSave,
onRemove,
}: {
tip: LoadingScreenTip;
onChange: (next: LoadingScreenTip) => void;
onSave: () => void;
onRemove: () => void;
}) {
const toast = useToast();
const fileInput = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleFile = async (file: File) => {
if (!file.type.startsWith('image/')) {
toast.push('Please pick an image file', 'error');
return;
}
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await api<{ imageName: string; imageUrl: string; bytes: number; reused: boolean }>(
'/loadingscreentips/upload',
{ method: 'POST', formData: fd, timeoutMs: 60_000 },
);
onChange({ ...tip, imageName: res.imageName });
toast.push(res.reused ? 'Image already on file — reused' : `Uploaded (${(res.bytes / 1024).toFixed(0)} KB)`, 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setUploading(false);
if (fileInput.current) fileInput.current.value = '';
}
};
const apex = typeof window !== 'undefined' && window.location.host.endsWith('localhost') ? 'localhost' : 'rec.net';
const preview = tip.imageName ? `https://img.${apex}/${encodeURIComponent(tip.imageName)}?width=160&sig=p1` : null;
return (
<div className="card !p-4 space-y-3">
<div className="flex gap-3">
<div
className="shrink-0 size-24 rounded bg-ink-900 border border-ink-800 overflow-hidden flex items-center justify-center cursor-pointer hover:border-brand-500"
onClick={() => fileInput.current?.click()}
title="Click to upload"
>
{preview ? (
<img src={preview} className="size-full object-cover" alt="" />
) : (
<span className="text-[10px] text-ink-500 px-1 text-center">{uploading ? 'Uploading…' : 'Pick image'}</span>
)}
</div>
<input
ref={fileInput}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
<div className="flex-1 space-y-2">
<input
value={tip.title}
onChange={e => onChange({ ...tip, title: e.target.value })}
placeholder="Tip title (shown bold on the splash)"
className="input text-sm w-full"
maxLength={128}
/>
<textarea
value={tip.message}
onChange={e => onChange({ ...tip, message: e.target.value })}
placeholder="Tip body (the actual text the player reads)"
className="input text-xs w-full"
rows={2}
maxLength={512}
/>
<div className="flex gap-1.5 items-center">
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={uploading}
className="btn-secondary text-xs"
>
{uploading ? 'Uploading…' : preview ? 'Replace image' : 'Upload image'}
</button>
{tip.imageName && (
<button
type="button"
onClick={() => onChange({ ...tip, imageName: '' })}
className="btn-ghost text-xs text-danger"
>
Clear image
</button>
)}
<span className="font-mono text-[11px] text-ink-400 truncate flex-1" title={tip.imageName}>
{tip.imageName || <span className="italic text-ink-600">no image</span>}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2 text-xs">
<label className="flex flex-col gap-1">
<span className="text-ink-400">Room scope (csv, optional)</span>
<input
value={tip.roomNamesCsv}
onChange={e => onChange({ ...tip, roomNamesCsv: e.target.value })}
placeholder="DormRoom,MakerRoom"
className="input text-xs"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-ink-400">Context (int)</span>
<input
type="number"
value={tip.context}
onChange={e => onChange({ ...tip, context: parseInt(e.target.value) || 0 })}
className="input text-xs"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-ink-400">Platform mask</span>
<input
type="number"
value={tip.platformMask}
onChange={e => onChange({ ...tip, platformMask: parseInt(e.target.value) || -1 })}
className="input text-xs"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-ink-400">Sort order</span>
<input
type="number"
value={tip.sortOrder}
onChange={e => onChange({ ...tip, sortOrder: parseInt(e.target.value) || 0 })}
className="input text-xs"
/>
</label>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-ink-300 cursor-pointer">
<input
type="checkbox"
checked={tip.isActive}
onChange={e => onChange({ ...tip, isActive: e.target.checked })}
/>
Active (served to watch)
</label>
<div className="flex-1" />
<button onClick={onSave} className="btn-primary text-xs">Save</button>
<button onClick={onRemove} className="btn-ghost text-xs text-danger" title="Delete tip">
<Trash />
</button>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { api, ApiError } from '../lib/api';
import { notifyAuthChange, setSession } from '../lib/auth';
interface LoginResponse {
access_token: string;
refresh_token: string;
account_id: number;
username: string;
display_name: string;
}
export function Login() {
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
setLoading(true);
try {
const res = await api<LoginResponse>('/login', {
method: 'POST',
body: { username, password },
});
setSession(res.access_token, {
id: res.account_id,
username: res.username,
displayName: res.display_name,
});
notifyAuthChange();
navigate(from, { replace: true });
} catch (e) {
if (e instanceof ApiError) {
if (e.body && typeof e.body === 'object' && 'error' in e.body) {
const code = (e.body as { error: string }).error;
if (code === 'not_admin') setErr('That account is not an admin.');
else if (code === 'invalid_credentials') setErr('Invalid username or password.');
else setErr(e.message);
} else setErr(e.message);
} else setErr(String(e));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-full flex items-center justify-center px-4 py-12 bg-gradient-to-br from-ink-950 via-ink-950 to-ink-900">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-2">
<div className="size-12 rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 flex items-center justify-center text-xl font-bold text-white shadow-lg shadow-brand-900/40">D</div>
<div className="text-center">
<h1 className="text-xl font-semibold tracking-tight text-ink-50">DorkNet Admin</h1>
<p className="text-xs text-ink-400">Sign in with an admin account.</p>
</div>
</div>
<form onSubmit={onSubmit} className="card p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="label" htmlFor="username">Username</label>
<input
id="username"
autoComplete="username"
required
className="input"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="label" htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
className="input"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</div>
{err && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger">{err}</div>
)}
<button type="submit" disabled={loading} className="btn-primary mt-1">
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="mt-6 text-center text-[11px] text-ink-500">DorkNet private server · admin console</p>
</div>
</div>
);
}
@@ -0,0 +1,109 @@
import { useState } from 'react';
import { api } from '../lib/api';
import type { Player } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
export function Passwords() {
const { data: players } = useApi<Player[]>('/players?take=500');
const [playerId, setPlayerId] = useState<number | null>(null);
const [pw, setPw] = useState('');
const [pw2, setPw2] = useState('');
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const toast = useToast();
const random = () => {
// Simple human-friendly random password — letters, digits, and a
// touch of punctuation. 16 chars is comfortably above the BCrypt
// 72-byte cap and the server's 8-char minimum.
const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!?#@*';
const buf = new Uint32Array(16);
crypto.getRandomValues(buf);
const next = Array.from(buf, n => alphabet[n % alphabet.length]).join('');
setPw(next); setPw2(next); setShow(true);
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!playerId) return;
if (pw !== pw2) return toast.push("Passwords don't match", 'error');
if (pw.length < 8) return toast.push('Password must be at least 8 characters', 'error');
const player = players?.find(p => p.id === playerId);
if (!confirm(`Reset password for @${player?.username ?? `#${playerId}`}? This forces a logout on every active session.`)) return;
setBusy(true);
try {
await api(`/players/${playerId}/password`, {
method: 'POST',
body: { NewPassword: pw },
});
toast.push('Password updated', 'success');
setPw(''); setPw2(''); setShow(false);
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<div>
<PageHeader
title="Reset passwords"
blurb="Admin-set a new password for any account. Issues a Logout push so any active sessions on the old password get dropped."
/>
<form onSubmit={submit} className="card !p-5 max-w-xl space-y-4">
<label className="flex flex-col gap-1">
<span className="label">Account</span>
<select value={playerId ?? ''} onChange={e => setPlayerId(e.target.value ? parseInt(e.target.value) : null)} required className="input">
<option value="">— pick an account —</option>
{(players ?? []).map(p => <option key={p.id} value={p.id}>{p.displayName || p.username} · @{p.username} · #{p.id}</option>)}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="label">New password</span>
<div className="flex gap-2">
<input
type={show ? 'text' : 'password'}
value={pw}
onChange={e => setPw(e.target.value)}
minLength={8}
required
autoComplete="new-password"
className="input flex-1 font-mono"
/>
<button type="button" onClick={() => setShow(s => !s)} className="btn-ghost text-xs">{show ? 'Hide' : 'Show'}</button>
<button type="button" onClick={random} className="btn-secondary text-xs">Random</button>
</div>
</label>
<label className="flex flex-col gap-1">
<span className="label">Confirm new password</span>
<input
type={show ? 'text' : 'password'}
value={pw2}
onChange={e => setPw2(e.target.value)}
minLength={8}
required
autoComplete="new-password"
className="input font-mono"
/>
</label>
<div className="rounded-lg border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
The plaintext is hashed with BCrypt before write — we never store it. The audit log records the action but not the password.
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => { setPw(''); setPw2(''); setShow(false); }} className="btn-ghost text-xs" disabled={busy}>Clear</button>
<button className="btn-primary text-xs" disabled={busy || !playerId || pw.length < 8 || pw !== pw2}>
{busy ? 'Updating…' : 'Update password'}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from 'react';
import { get } from '../lib/api';
import type { Player, PlayerLogEntry } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { Empty } from '../components/Empty';
import { absoluteTime, clip, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons';
export function PlayerLogs() {
const { data: players } = useApi<Player[]>('/players?take=500');
const [pid, setPid] = useState<number | null>(null);
const [filter, setFilter] = useState('');
const [take, setTake] = useState(200);
const [auto, setAuto] = useState(false);
const [rows, setRows] = useState<PlayerLogEntry[] | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const load = useMemo(() => async () => {
if (pid === null) return;
setLoading(true);
setErr(null);
try {
const data = await get<PlayerLogEntry[]>(`/players/${pid}/logs?take=${take}`);
setRows(data);
} catch (e) {
setErr((e as Error).message);
} finally {
setLoading(false);
}
}, [pid, take]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!auto) return;
const id = setInterval(load, 5000);
return () => clearInterval(id);
}, [auto, load]);
const filtered = (rows ?? []).filter(r => {
if (!filter.trim()) return true;
const f = filter.toLowerCase();
return r.path.toLowerCase().includes(f)
|| String(r.status).includes(f)
|| r.method.toLowerCase().includes(f)
|| r.host.toLowerCase().includes(f);
});
return (
<div>
<PageHeader
title="Player request logs"
blurb="Last few hundred HTTP calls per player, pulled from the Redis ring buffer. Useful when a player reports something weird."
/>
<div className="card !p-4 mb-4 grid grid-cols-1 md:grid-cols-[2fr,1fr,120px,auto,auto] gap-2 items-end">
<label className="flex flex-col gap-1">
<span className="label">Player</span>
<select value={pid ?? ''} onChange={e => setPid(e.target.value ? parseInt(e.target.value) : null)} className="input">
<option value="">— pick a player —</option>
{(players ?? []).map(p => (
<option key={p.id} value={p.id}>
{p.displayName || p.username} · @{p.username} · #{p.id}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="label">Filter</span>
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="path, host, status…" className="input" />
</label>
<label className="flex flex-col gap-1">
<span className="label">Take</span>
<input type="number" min={10} max={500} value={take} onChange={e => setTake(parseInt(e.target.value || '200'))} className="input" />
</label>
<label className="flex items-center gap-2 text-xs text-ink-300 pb-1">
<input type="checkbox" checked={auto} onChange={e => setAuto(e.target.checked)} className="size-4 accent-brand-500" />
auto 5s
</label>
<button onClick={load} className="btn-secondary text-xs h-9" disabled={loading || pid === null}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{err && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>}
<div className="card overflow-hidden">
{pid === null && <Empty title="Pick a player" blurb="Logs load once you select someone above." />}
{pid !== null && rows && filtered.length === 0 && (
<Empty title={filter ? 'No log lines match the filter' : 'No log entries for this player yet'} />
)}
{filtered.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[720px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800 sticky top-0">
<tr>
<th className="text-left font-medium px-3 py-2">When</th>
<th className="text-left font-medium px-3 py-2">Status</th>
<th className="text-left font-medium px-3 py-2">Method</th>
<th className="text-left font-medium px-3 py-2">Host</th>
<th className="text-left font-medium px-3 py-2">Path</th>
<th className="text-right font-medium px-3 py-2">ms</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800 font-mono text-xs">
{filtered.map((r, i) => (
<tr key={i} className="table-row-hover">
<td className="px-3 py-1.5 text-ink-400 whitespace-nowrap" title={absoluteTime(r.timestamp)}>{relativeTime(r.timestamp)}</td>
<td className={`px-3 py-1.5 tabular-nums ${r.status >= 500 ? 'text-danger' : r.status >= 400 ? 'text-warn' : 'text-success'}`}>{r.status}</td>
<td className="px-3 py-1.5 text-ink-200">{r.method}</td>
<td className="px-3 py-1.5 text-ink-300">{r.host}</td>
<td className="px-3 py-1.5 text-ink-100" title={r.path + (r.query ? '?' + r.query : '')}>
{clip(r.path + (r.query ? '?' + r.query : ''), 100)}
</td>
<td className="px-3 py-1.5 text-right text-ink-300 tabular-nums">{r.elapsedMs}</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
</div>
);
}
@@ -0,0 +1,528 @@
import { useEffect, useMemo, useState } from 'react';
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';
export function Players() {
const [query, setQuery] = useState('');
const [rows, setRows] = useState<Player[] | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(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<Player[]>(`/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]);
return (
<div>
<PageHeader
title="Players"
blurb="Search and moderate every account in the database."
actions={
<button onClick={() => search(query)} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
}
/>
<div className="card overflow-hidden">
<div className="border-b border-ink-800 p-3 flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search username or display name…"
className="input pl-8"
/>
</div>
<div className="text-xs text-ink-400 ml-auto">
{rows ? `${rows.length} match${rows.length === 1 ? '' : 'es'}` : ''}
</div>
</div>
{err && <div className="px-4 py-3 text-sm text-danger border-b border-danger/30 bg-danger/5">{err}</div>}
<div className="table-scroll">
<table className="w-full text-sm min-w-[640px]">
<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">Account</th>
<th className="text-left font-medium px-4 py-2.5">Status</th>
<th className="text-right font-medium px-4 py-2.5">Level</th>
<th className="text-left font-medium px-4 py-2.5">Last seen</th>
<th className="text-left font-medium px-4 py-2.5">Joined</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{rows?.map(p => (
<tr
key={p.id}
className="table-row-hover cursor-pointer"
onClick={() => setSelectedId(p.id)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={32} />
<div className="min-w-0">
<div className="font-medium text-ink-50 truncate">{p.displayName || p.username}</div>
<div className="text-xs text-ink-400 truncate">@{p.username} <span className="text-ink-600">·</span> #{p.id}</div>
</div>
</div>
</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{p.online && <span className="badge-online">Online</span>}
{p.bannedUntil && new Date(p.bannedUntil) > new Date() && <span className="badge-banned">Banned</span>}
{p.isAdmin && <span className="badge-admin">Admin</span>}
{p.isDeveloper && <span className="badge-neutral">Dev</span>}
{p.isCommunityTeam && <span className="badge-neutral">Community</span>}
{p.isJunior && <span className="badge-junior">Junior</span>}
{p.isVerified && <span className="badge-neutral">Verified</span>}
{!p.online && !p.bannedUntil && !p.isAdmin && !p.isDeveloper && !p.isCommunityTeam && !p.isJunior && !p.isVerified && (
<span className="text-xs text-ink-500">—</span>
)}
</div>
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-200">{p.level}</td>
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(p.lastSeenAt)}>{relativeTime(p.lastSeenAt)}</td>
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(p.createdAt)}>{relativeTime(p.createdAt)}</td>
<td className="px-4 py-2.5 text-right">
<button className="btn-ghost text-xs">Open →</button>
</td>
</tr>
))}
{rows && rows.length === 0 && (
<tr><td colSpan={6}><Empty title="No matches" blurb="Try a different name or clear the filter." /></td></tr>
)}
{!rows && !err && (
<tr><td colSpan={6} className="py-10 text-center text-xs text-ink-400">Loading players…</td></tr>
)}
</tbody>
</table>
</div>
</div>
{selectedId !== null && (
<PlayerDetail
id={selectedId}
onClose={() => setSelectedId(null)}
onChanged={() => { setSelectedId(null); search(query); }}
/>
)}
</div>
);
}
// ── Detail / edit modal ───────────────────────────────────────────────
function PlayerDetail({ id, onClose, onChanged }: { id: number; onClose: () => void; onChanged: () => void }) {
const [data, setData] = useState<PlayerDetail | null>(null);
const [err, setErr] = useState<string | null>(null);
const [tab, setTab] = useState<'overview' | 'mod' | 'grants' | 'profile'>('overview');
const reload = () => {
setErr(null);
get<PlayerDetail>(`/players/${id}`)
.then(setData)
.catch(e => setErr((e as Error).message));
};
useEffect(reload, [id]);
return (
<Modal title={`Player #${id}`} open onClose={onClose} size="xl">
{err && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger mb-3">{err}</div>}
{!data && !err && <div className="py-10 text-center text-xs text-ink-400">Loading…</div>}
{data && (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<PlayerAvatar name={data.profileImageName} displayName={data.displayName || data.username} size={56} />
<div className="min-w-0">
<div className="text-lg font-semibold text-ink-50 truncate">{data.displayName || data.username}</div>
<div className="text-xs text-ink-400 truncate">@{data.username} · #{data.id} · {data.email ?? 'no email'}</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{data.online && <span className="badge-online">Online</span>}
{data.bannedUntil && new Date(data.bannedUntil) > new Date() && (
<span className="badge-banned" title={absoluteTime(data.bannedUntil)}>Banned · expires {relativeTime(data.bannedUntil)}</span>
)}
{data.isAdmin && <span className="badge-admin">Admin</span>}
{data.isDeveloper && <span className="badge-neutral">Developer</span>}
{data.isCommunityTeam && <span className="badge-neutral">Community Team</span>}
{data.isVerified && <span className="badge-neutral">Verified</span>}
{data.isJunior && <span className="badge-junior">Junior</span>}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<Stat label="Level" value={num(data.level)} />
<Stat label="XP" value={num(data.xp)} />
<Stat label="Last IP" value={data.lastIp ?? '—'} mono />
</div>
</div>
<div className="flex border-b border-ink-800 text-sm">
{(['overview', 'mod', 'grants', 'profile'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-2 -mb-px border-b-2 capitalize ${tab === t ? 'border-brand-500 text-ink-50' : 'border-transparent text-ink-300 hover:text-ink-100'}`}
>
{t === 'mod' ? 'Moderation' : t}
</button>
))}
</div>
{tab === 'overview' && <OverviewTab data={data} />}
{tab === 'mod' && <ModerationTab data={data} onChanged={onChanged} />}
{tab === 'grants' && <GrantsTab data={data} onChanged={reload} />}
{tab === 'profile' && <ProfileTab data={data} onChanged={reload} />}
</div>
)}
</Modal>
);
}
function Stat({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="card !p-2 text-center">
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className={`text-sm font-medium text-ink-50 ${mono ? 'font-mono text-xs' : ''}`}>{value}</div>
</div>
);
}
function OverviewTab({ data }: { data: PlayerDetail }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<Field label="Display name" value={data.displayName} />
<Field label="Username" value={`@${data.username}`} />
<Field label="Email" value={data.email ?? '—'} />
<Field label="Joined" value={absoluteTime(data.createdAt)} />
<Field label="Last seen" value={absoluteTime(data.lastSeenAt)} />
<Field label="Last IP" value={data.lastIp ?? '—'} mono />
<Field label="Bio" full value={data.bio || <span className="text-ink-500">no bio</span>} />
<div className="card !p-3 md:col-span-2">
<div className="text-[11px] uppercase tracking-widest text-ink-400 mb-2">Wallet</div>
{data.balances.length === 0 ? (
<div className="text-xs text-ink-500">No currency balances on file.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{data.balances.map(b => (
<div key={b.currencyType} className="rounded border border-ink-800 px-3 py-2">
<div className="text-[10px] uppercase tracking-widest text-ink-400">{currencyName(b.currencyType)}</div>
<div className="text-sm font-medium text-ink-50 tabular-nums">{num(b.balance)}</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
function Field({ label, value, full, mono }: { label: string; value: React.ReactNode; full?: boolean; mono?: boolean }) {
return (
<div className={`card !p-3 ${full ? 'md:col-span-2' : ''}`}>
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className={`mt-0.5 text-sm text-ink-100 ${mono ? 'font-mono text-xs' : ''}`}>{value}</div>
</div>
);
}
// ── 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<string | null>(null);
const [confirmKey, setConfirmKey] = useState<null | 'ban' | 'unban' | 'kick' | 'promote' | 'demote'>(null);
const run = async (label: string, fn: () => Promise<unknown>) => {
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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Ban / unban</h3>
<p className="text-xs text-ink-400 mb-3">
{banned ? `Currently banned until ${absoluteTime(data.bannedUntil)}.` : 'Temporary ban with optional reason.'}
</p>
<div className="flex items-center gap-2 mb-2">
<input type="number" min={1} max={3650} value={banDays} onChange={e => setBanDays(parseInt(e.target.value || '1'))} className="input w-24" />
<span className="text-xs text-ink-400">days</span>
</div>
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (optional, logged)" className="input mb-2 text-xs" />
<div className="flex gap-2">
<button onClick={() => setConfirmKey('ban')} className="btn-danger text-xs" disabled={!!busy || data.isAdmin}>
{busy === 'Ban' ? 'Banning…' : 'Ban player'}
</button>
{banned && (
<button onClick={() => setConfirmKey('unban')} className="btn-secondary text-xs" disabled={!!busy}>
Unban
</button>
)}
</div>
{data.isAdmin && <p className="text-[11px] text-warn mt-2">Demote this admin first.</p>}
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Kick</h3>
<p className="text-xs text-ink-400 mb-3">Sends a ModerationKick push so the watch boots out of its current session. The player can reconnect immediately.</p>
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (optional)" className="input mb-2 text-xs" />
<button onClick={() => setConfirmKey('kick')} className="btn-secondary text-xs" disabled={!!busy}>Kick player</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Admin role</h3>
<p className="text-xs text-ink-400 mb-3">
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.
</p>
<div className="flex gap-2">
{!data.isAdmin && (
<button onClick={() => setConfirmKey('promote')} className="btn-primary text-xs" disabled={!!busy}>Promote to admin</button>
)}
{data.isAdmin && (
<button onClick={() => setConfirmKey('demote')} className="btn-danger text-xs" disabled={!!busy}>Revoke admin</button>
)}
</div>
</div>
<Confirm
open={confirmKey === 'ban'}
onClose={() => 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 } }))}
/>
<Confirm
open={confirmKey === 'unban'}
onClose={() => 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 } }))}
/>
<Confirm
open={confirmKey === 'kick'}
onClose={() => 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 } }))}
/>
<Confirm
open={confirmKey === 'promote'}
onClose={() => 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' }))}
/>
<Confirm
open={confirmKey === 'demote'}
onClose={() => 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' }))}
/>
</div>
);
}
// ── Grants: currency, XP, item, displayName, flags ───────────────────
function GrantsTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
const toast = useToast();
const run = async (label: string, fn: () => Promise<unknown>) => {
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 [itemId, setItemId] = useState('');
const [itemQty, setItemQty] = useState(1);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Adjust currency</h3>
<p className="text-xs text-ink-400 mb-3">Positive grants, negative deducts (clamped to zero).</p>
<div className="flex gap-2 mb-2">
<select value={currType} onChange={e => setCurrType(parseInt(e.target.value))} className="input w-32">
<option value={1}>Tokens</option>
<option value={2}>Coins</option>
</select>
<input type="number" value={currAmount} onChange={e => setCurrAmount(parseInt(e.target.value || '0'))} className="input flex-1" />
</div>
<button
onClick={() => run('Currency', () => api(`/players/${data.id}/currency`, { method: 'POST', body: { CurrencyType: currType, Amount: currAmount, Reason: 'admin_grant' } }))}
className="btn-primary text-xs"
>Apply</button>
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Grant XP</h3>
<p className="text-xs text-ink-400 mb-3">May trigger level-up rewards.</p>
<div className="flex gap-2 mb-2">
<input type="number" value={xp} onChange={e => setXp(parseInt(e.target.value || '0'))} className="input" />
</div>
<button
onClick={() => run('XP', () => api(`/players/${data.id}/xp`, { method: 'POST', body: { Amount: xp, Reason: 'admin_grant' } }))}
className="btn-primary text-xs"
>Grant XP</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Grant inventory item</h3>
<p className="text-xs text-ink-400 mb-3">Appends an item GUID to the player's inventory JSON. Use the Store catalog to find slugs / GUIDs.</p>
<div className="grid grid-cols-1 sm:grid-cols-[1fr,120px,auto] gap-2">
<input value={itemId} onChange={e => setItemId(e.target.value)} placeholder="item GUID" className="input font-mono text-xs" />
<input type="number" min={1} max={1000} value={itemQty} onChange={e => setItemQty(parseInt(e.target.value || '1'))} className="input" />
<button
onClick={() => run('Item', () => api(`/players/${data.id}/inventory/grant`, { method: 'POST', body: { ItemId: itemId.trim(), Quantity: itemQty } }))}
disabled={!itemId.trim()}
className="btn-primary text-xs"
>Grant</button>
</div>
</div>
</div>
);
}
// ── Profile: display name + role flags ───────────────────────────────
function ProfileTab({ data, onChanged }: { data: PlayerDetail; onChanged: () => void }) {
const toast = useToast();
const [displayName, setDisplayName] = useState(data.displayName);
const [flags, setFlags] = useState({
isVerified: data.isVerified,
isDeveloper: data.isDeveloper,
isCommunityTeam: data.isCommunityTeam,
isJunior: data.isJunior,
});
const save = async (label: string, fn: () => Promise<unknown>) => {
try {
await fn();
toast.push(`${label} saved`, 'success');
onChanged();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Display name</h3>
<p className="text-xs text-ink-400 mb-3">Force-override the player's display name.</p>
<div className="flex gap-2">
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="input flex-1" />
<button
onClick={() => save('Display name', () => api(`/players/${data.id}/displayName`, { method: 'POST', body: { DisplayName: displayName } }))}
disabled={!displayName.trim() || displayName === data.displayName}
className="btn-primary text-xs"
>Save</button>
</div>
</div>
<div className="card !p-4">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Profile flags</h3>
<p className="text-xs text-ink-400 mb-3">
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.
</p>
<div className="flex flex-col gap-2 mb-3">
{([
['isVerified', 'Verified'],
['isDeveloper', 'Developer'],
['isCommunityTeam', 'Community Team'],
['isJunior', 'Junior'],
] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-2 text-sm text-ink-200">
<input
type="checkbox"
checked={flags[k]}
onChange={e => setFlags(f => ({ ...f, [k]: e.target.checked }))}
className="size-4 accent-brand-500"
/>
{label}
</label>
))}
</div>
<button
onClick={() => save('Flags', () => api(`/players/${data.id}/flags`, { method: 'POST', body: { IsVerified: flags.isVerified, IsDeveloper: flags.isDeveloper, IsCommunityTeam: flags.isCommunityTeam, IsJunior: flags.isJunior } }))}
className="btn-primary text-xs"
>Save flags</button>
</div>
<div className="card !p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-ink-50 mb-1">Reset avatar</h3>
<p className="text-xs text-ink-400 mb-3">
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 <code className="text-[11px]">outfits_assets_*.bundle</code> on disk
(the build can't load it and Unity's Addressables tears down the local player).
</p>
<button
onClick={() => {
if (!confirm(`Reset @${data.username}'s avatar to the starter outfit and inventory? Existing wardrobe items will be removed.`)) return;
save('Avatar reset', () => api(`/players/${data.id}/avatar/reset`, { method: 'POST' }));
}}
className="btn-danger text-xs"
>Reset to starter</button>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import { useState } from 'react';
import { api } from '../lib/api';
import type { Report } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { Empty } from '../components/Empty';
import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast';
import { absoluteTime, relativeTime } from '../lib/format';
import { RefreshCw } from '../components/Icons';
export function Reports() {
const { data, loading, error, refresh } = useApi<Report[]>('/reports?take=100');
const [resolving, setResolving] = useState<Report | null>(null);
return (
<div>
<PageHeader
title="Open reports"
blurb="Oldest first. Resolving a report only closes the ticket — issue the actual moderation action separately."
actions={
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
}
/>
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{error}</div>}
<div className="card overflow-hidden">
{data && data.length === 0 && <Empty title="No open reports" blurb="When a player reports another, it lands here." />}
{data && data.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
<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">#</th>
<th className="text-left font-medium px-4 py-2.5">Reporter</th>
<th className="text-left font-medium px-4 py-2.5">Target</th>
<th className="text-left font-medium px-4 py-2.5">Reason</th>
<th className="text-left font-medium px-4 py-2.5">Filed</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{data.map(r => (
<tr key={r.id} className="table-row-hover">
<td className="px-4 py-2.5 text-ink-400 tabular-nums">{r.id}</td>
<td className="px-4 py-2.5 text-ink-200">#{r.reporterPlayerId}</td>
<td className="px-4 py-2.5 text-ink-200">#{r.targetPlayerId}</td>
<td className="px-4 py-2.5 text-ink-100 max-w-md">
<div className="font-medium">{r.reason || '—'}</div>
{r.context && <div className="text-xs text-ink-400 mt-0.5 line-clamp-2">{r.context}</div>}
</td>
<td className="px-4 py-2.5 text-ink-300 text-xs" title={absoluteTime(r.createdAt)}>{relativeTime(r.createdAt)}</td>
<td className="px-4 py-2.5 text-right">
<button onClick={() => setResolving(r)} className="btn-primary text-xs">Resolve</button>
</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
{resolving && <ResolveModal report={resolving} onClose={() => setResolving(null)} onResolved={() => { setResolving(null); refresh(); }} />}
</div>
);
}
function ResolveModal({ report, onClose, onResolved }: { report: Report; onClose: () => void; onResolved: () => void }) {
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast();
const submit = async () => {
setBusy(true);
try {
await api(`/reports/${report.id}/resolve`, { method: 'POST', body: { Note: note } });
toast.push('Report resolved', 'success');
onResolved();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
title={`Resolve report #${report.id}`}
open
onClose={onClose}
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button onClick={submit} className="btn-primary text-xs" disabled={busy}>{busy ? 'Resolving…' : 'Resolve'}</button>
</>}
>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="card !p-2"><div className="text-ink-400">Reporter</div><div className="text-ink-100">#{report.reporterPlayerId}</div></div>
<div className="card !p-2"><div className="text-ink-400">Target</div><div className="text-ink-100">#{report.targetPlayerId}</div></div>
</div>
<div className="card !p-3">
<div className="text-[11px] uppercase tracking-widest text-ink-400">Reason</div>
<div className="text-ink-100 mt-1">{report.reason || '—'}</div>
</div>
{report.context && (
<div className="card !p-3">
<div className="text-[11px] uppercase tracking-widest text-ink-400">Context</div>
<div className="text-ink-200 text-xs mt-1 whitespace-pre-wrap">{report.context}</div>
</div>
)}
<label className="block">
<div className="label mb-1">Resolution note (logged to audit)</div>
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} className="input" placeholder="warned · dismissed · banned 7d · etc." />
</label>
</div>
</Modal>
);
}
+1025
View File
@@ -0,0 +1,1025 @@
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>
);
}
+274
View File
@@ -0,0 +1,274 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../lib/api';
import type { Room } from '../lib/types';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { Empty } from '../components/Empty';
import { Modal } from '../components/Modal';
import { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm';
import { RefreshCw, Trash } from '../components/Icons';
export function Rooms() {
const { data, loading, error, refresh } = useApi<Room[]>('/rooms');
const [filter, setFilter] = useState('');
const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all');
const [pendingDelete, setPendingDelete] = useState<Room | null>(null);
const [pendingPurge, setPendingPurge] = useState<Room | null>(null);
const toast = useToast();
// Truth table for what's actually canonical:
// isSeededOriginal = IsAGRoom AND creator == system seed (id 1).
// isUserOwnedCustom = NOT dorm AND creator != system seed.
// (covers user-cloned rooms that incorrectly
// inherited IsAGRoom=true from their source.)
// isDorm = IsDormRoom.
// IsAGRoom alone is not a reliable "is this canonical?" signal,
// because user-cloned rooms inherited it from RR Originals for years
// before we fixed the clone path.
const SYSTEM_ACCOUNT_ID = 1;
const isSeededOriginal = (r: Room) => r.isAGRoom && r.creatorPlayerId === SYSTEM_ACCOUNT_ID && !r.isDormRoom;
const isUserCustom = (r: Room) => !r.isDormRoom && r.creatorPlayerId !== SYSTEM_ACCOUNT_ID;
const filtered = (data ?? []).filter(r => {
if (filterKind === 'original' && !isSeededOriginal(r)) return false;
if (filterKind === 'custom' && !isUserCustom(r)) return false;
if (filterKind === 'dorm' && !r.isDormRoom) return false;
if (filter.trim() && !r.name.toLowerCase().includes(filter.toLowerCase())) return false;
return true;
});
const del = async () => {
if (!pendingDelete) return;
try {
await api(`/rooms/${pendingDelete.id}`, { method: 'DELETE', body: { Reason: 'admin' } });
toast.push(`Archived ${pendingDelete.name}`, 'success');
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
return (
<div>
<PageHeader
title="Rooms"
blurb="Every room in the DB. Archive (trash icon) is soft and reversible. Purge is a hard wipe — drops scene rows + per-room blobs + thumbnail and requires typing the room name twice."
actions={<>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</>}
/>
<div className="card overflow-hidden">
<div className="flex flex-wrap items-center gap-2 border-b border-ink-800 px-3 py-2.5">
<input value={filter} onChange={e => setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" />
<div className="flex gap-1">
{(['all', 'original', 'custom', 'dorm'] as const).map(k => (
<button
key={k}
onClick={() => setFilterKind(k)}
className={`px-2.5 py-1 rounded-md text-xs ${filterKind === k ? 'bg-brand-500/15 text-brand-100 ring-1 ring-inset ring-brand-500/30' : 'text-ink-300 hover:bg-ink-800'}`}
>
{k === 'all' ? 'All' : k === 'original' ? 'RR Originals' : k === 'custom' ? 'Custom' : 'Dorms'}
</button>
))}
</div>
<div className="ml-auto text-xs text-ink-400">
{data ? `${filtered.length} / ${data.length}` : ''}
</div>
</div>
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
{data && filtered.length === 0 && <Empty title="No rooms match" />}
{filtered.length > 0 && (
<div className="table-scroll"><table className="w-full text-sm min-w-[640px]">
<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">#</th>
<th className="text-left font-medium px-4 py-2.5">Name</th>
<th className="text-left font-medium px-4 py-2.5">Type</th>
<th className="text-right font-medium px-4 py-2.5">Creator</th>
<th className="text-right font-medium px-4 py-2.5">Blobs</th>
<th />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{filtered.map(r => (
<tr key={r.id} className="table-row-hover">
<td className="px-4 py-2.5 text-ink-400 tabular-nums">{r.id}</td>
<td className="px-4 py-2.5 font-medium">
{/* Whole row's primary action is "open detail view"
— the link makes the room name keyboard- and
middle-click-friendly, and the click target
large enough on mobile. Archive/Purge stay as
action buttons in the rightmost cell. */}
<Link to={`/rooms/${r.id}`} className="text-ink-50 hover:text-brand-200">{r.name}</Link>
</td>
<td className="px-4 py-2.5">
{r.isDormRoom && <span className="badge-neutral">Dorm</span>}
{/* "RR Original" badge is reserved for rows the
system seed account owns. User clones that
inherited IsAGRoom=true from their source are
rendered as Custom — that matches reality. */}
{isSeededOriginal(r) && <span className="badge-admin">RR Original</span>}
{isUserCustom(r) && <span className="badge-neutral">Custom</span>}
</td>
<td className="px-4 py-2.5 text-right text-ink-300 text-xs">
{r.creatorPlayerId ? `#${r.creatorPlayerId}` : '—'}
</td>
<td className="px-4 py-2.5 text-right text-ink-200 tabular-nums">{r.blobCount}</td>
<td className="px-4 py-2.5 text-right whitespace-nowrap">
<Link to={`/rooms/${r.id}`} className="btn-ghost text-xs">Manage</Link>
<button onClick={() => setPendingDelete(r)} className="btn-ghost text-xs text-danger ml-1" title="Soft archive">
<Trash />
</button>
</td>
</tr>
))}
</tbody>
</table></div>
)}
</div>
<Confirm
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
title="Archive room"
body={<>Set <span className="font-medium text-ink-50">{pendingDelete?.name}</span> to State=1 (Archived)? The room will stop appearing in browse/search but stays restorable.</>}
confirmLabel="Archive"
destructive
onConfirm={del}
/>
{pendingPurge && (
<PurgeRoomModal
room={pendingPurge}
onClose={() => setPendingPurge(null)}
onPurged={refresh}
/>
)}
</div>
);
}
// ── Per-room hard-delete modal with two typed-name confirmations ─────────
// The previous bulk "purge all custom" button was removed — too easy to
// click by accident. Each room now gets its own purge flow that requires
// typing the room's exact name TWICE in separate inputs (case-sensitive
// per the server's StringComparison.Ordinal check). Same pattern GitHub
// uses for "delete repository" — slows a fat finger down enough to read
// what they're about to wipe.
function PurgeRoomModal({
room,
onClose,
onPurged,
}: {
room: Room;
onClose: () => void;
onPurged: () => void;
}) {
const [confirm1, setConfirm1] = useState('');
const [confirm2, setConfirm2] = useState('');
const [busy, setBusy] = useState(false);
const toast = useToast();
// Both inputs MUST exactly equal the room name. The server validates
// identically — keeping the same comparison here means we disable the
// submit button rather than letting the user click through to a 400.
const ready = confirm1 === room.name && confirm2 === room.name && !busy;
// Reset typed values whenever the modal switches to a different room.
useEffect(() => {
setConfirm1('');
setConfirm2('');
}, [room.id]);
const submit = async () => {
if (!ready) 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',
);
onPurged();
onClose();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
open
onClose={onClose}
title="Purge room (hard delete)"
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button
onClick={submit}
disabled={!ready}
className="btn-danger text-xs"
title={!ready ? 'Type the room name in both inputs to enable purge' : undefined}
>
{busy ? 'Purging…' : 'Purge permanently'}
</button>
</>}
>
<div className="space-y-3 text-sm">
<p>
Hard-delete <span className="font-medium text-ink-50">{room.name}</span> (room #{room.id})?
This drops the room row, every scene, every per-room
<code className="font-mono text-xs mx-1">.room</code>
blob, and the thumbnail. Shared HTR / PV image assets stay. The
Name frees up so a re-import works.
</p>
<p className="text-xs text-warn">
Irreversible — no soft-archive fallback.
</p>
<div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">
To confirm, type the room name <span className="font-mono text-ink-100">{room.name}</span> in BOTH inputs below.
Case-sensitive.
</div>
<label className="flex flex-col gap-1">
<span className="label">Confirm name (1 of 2)</span>
<input
value={confirm1}
onChange={e => setConfirm1(e.target.value)}
className="input font-mono"
placeholder={room.name}
autoComplete="off"
spellCheck={false}
/>
</label>
<label className="flex flex-col gap-1">
<span className="label">Confirm name (2 of 2)</span>
<input
value={confirm2}
onChange={e => setConfirm2(e.target.value)}
className="input font-mono"
placeholder={room.name}
autoComplete="off"
spellCheck={false}
/>
</label>
</div>
</Modal>
);
}
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { api, get } from '../lib/api';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { RefreshCw } from '../components/Icons';
interface ServerSettings {
signupsDisabled: boolean;
updatedAt: string;
}
export function Settings() {
const [settings, setSettings] = useState<ServerSettings | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const toast = useToast();
const load = async () => {
try {
const s = await get<ServerSettings>('/settings');
setSettings(s);
setErr(null);
} catch (e) {
setErr((e as Error).message);
}
};
useEffect(() => { void load(); }, []);
const toggleSignups = async () => {
if (!settings) return;
const next = !settings.signupsDisabled;
const verb = next ? 'disable' : 'enable';
if (!confirm(`Really ${verb} new account creation? Existing players keep their access either way.`)) return;
setBusy(true);
try {
const updated = await api<ServerSettings>('/settings/signups', {
method: 'POST',
body: { Disabled: next },
});
setSettings(updated);
toast.push(next ? 'New signups blocked.' : 'New signups allowed.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<div>
<PageHeader
title="Server settings"
blurb="Runtime toggles applied across every replica without a redeploy."
actions={
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} />
Refresh
</button>
}
/>
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
)}
{!settings && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading…</div>
)}
{settings && (
<div className="card !p-5 max-w-2xl">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">In-game signups</h2>
<p className="mt-1 text-xs text-ink-400">
When disabled, the watch's account-creation flow returns an error to the player instead of minting a new account.
Existing logins keep working — only brand-new signups are blocked.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<button
onClick={toggleSignups}
disabled={busy}
className={(settings.signupsDisabled ? 'btn-primary' : 'btn-danger') + ' text-xs shrink-0'}
>
{busy ? 'Working…' : settings.signupsDisabled ? 'Enable signups' : 'Disable signups'}
</button>
</div>
<div className="mt-4 text-xs">
{settings.signupsDisabled
? <span className="badge-banned">Signups disabled</span>
: <span className="badge-online">Signups allowed</span>}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,210 @@
import { useEffect, useRef, useState } from 'react';
import { api, get } from '../lib/api';
import { PageHeader } from '../components/PageHeader';
import { Confirm } from '../components/Confirm';
import { useToast } from '../components/Toast';
import { HardDrive, RefreshCw } from '../components/Icons';
import { num } from '../lib/format';
// Wire shape returned by StorageBackfillController.BackfillStatus.
interface BackfillStatus {
running: boolean;
dryRun: boolean;
startedAt: string | null;
finishedAt: string | null;
total: number;
uploaded: number;
alreadyMirrored: number;
skipped: number;
failed: number;
lastBlobName: string | null;
elapsedMs: number | null;
error: string | null;
}
export function Storage() {
const [status, setStatus] = useState<BackfillStatus | null>(null);
const [err, setErr] = useState<string | null>(null);
const [pendingMode, setPendingMode] = useState<null | 'dry' | 'real'>(null);
const pollRef = useRef<number | null>(null);
const toast = useToast();
// Polling cadence:
// * 1 s while a run is active so the progress bar feels live.
// * 5 s when idle so the page doesn't hammer the server.
// Single source of truth — useEffect re-schedules itself based on
// the latest status.running.
useEffect(() => {
const fetchOnce = async () => {
try {
const s = await get<BackfillStatus>('/storage/backfill/status');
setStatus(s);
setErr(null);
} catch (e) {
setErr((e as Error).message);
}
};
void fetchOnce();
const interval = status?.running ? 1000 : 5000;
pollRef.current = window.setInterval(fetchOnce, interval);
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
}, [status?.running]);
const start = async (dryRun: boolean) => {
setPendingMode(null);
try {
await api(`/storage/backfill${dryRun ? '?dryRun=true' : ''}`, { method: 'POST' });
toast.push(dryRun ? 'Dry-run started' : 'Backfill started', 'success');
// Force a status refresh so the UI flips to running immediately.
const s = await get<BackfillStatus>('/storage/backfill/status');
setStatus(s);
} catch (e) {
const msg = (e as Error).message;
// 409 already-running is informational, not an error.
if (msg.includes('already_running')) toast.push('A backfill is already in progress', 'error');
else toast.push(msg, 'error');
}
};
const pct = status && status.total > 0
? Math.round(((status.uploaded + status.alreadyMirrored + status.skipped + status.failed) / status.total) * 100)
: 0;
const done = status && !status.running && status.startedAt !== null;
return (
<div>
<PageHeader
title="Storage backfill"
blurb="Move the legacy RoomDataBlobs.Bytes column out of Postgres and into S3 at the canonical key. Safe to run anytime — each row is HEAD-verified in S3 before the DB copy is dropped, and re-runs are idempotent."
actions={<>
<button
onClick={() => setPendingMode('dry')}
disabled={status?.running}
className="btn-secondary text-xs"
>
<HardDrive /> Dry-run
</button>
<button
onClick={() => setPendingMode('real')}
disabled={status?.running}
className="btn-primary text-xs"
>
<HardDrive /> Run backfill
</button>
</>}
/>
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">
{err}
</div>
)}
{status === null && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading status…</div>
)}
{status && (
<div className="space-y-4">
<div className="card !p-5">
<div className="flex flex-wrap items-center gap-3 justify-between mb-3">
<div className="flex items-center gap-2.5">
{status.running
? <RefreshCw className="animate-spin text-brand-300" />
: status.failed > 0
? <span className="badge-banned">Errors</span>
: done
? <span className="badge-online">Idle · last run OK</span>
: <span className="badge-neutral">Idle</span>
}
<div className="text-sm font-medium text-ink-50">
{status.running
? (status.dryRun ? 'Dry-run in progress…' : 'Backfill in progress…')
: done
? (status.dryRun ? 'Last run: dry-run' : 'Last run: live')
: 'No run yet'
}
</div>
</div>
<div className="text-xs text-ink-400 tabular-nums">
{status.running ? `${pct}%` : status.elapsedMs != null ? `${(status.elapsedMs / 1000).toFixed(1)}s` : ''}
</div>
</div>
<div className="h-2 rounded-full bg-ink-800 overflow-hidden">
<div
className={`h-full transition-all ${status.failed > 0 ? 'bg-danger' : 'bg-brand-500'}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-4 grid grid-cols-2 sm:grid-cols-5 gap-2 text-xs">
<Counter label="Total" value={num(status.total)} />
<Counter label="Uploaded" value={num(status.uploaded)} tone="success" />
<Counter label="Already" value={num(status.alreadyMirrored)} />
<Counter label="Skipped" value={num(status.skipped)} />
<Counter label="Failed" value={num(status.failed)} tone={status.failed > 0 ? 'danger' : undefined} />
</div>
{status.lastBlobName && (
<div className="mt-3 text-[11px] text-ink-400 font-mono truncate" title={status.lastBlobName}>
last: {status.lastBlobName}
</div>
)}
{status.error && (
<div className="mt-3 rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">
{status.error}
</div>
)}
</div>
<div className="card !p-4 text-xs text-ink-400 leading-relaxed space-y-2">
<p className="font-medium text-ink-200">What this does</p>
<ul className="list-disc list-inside space-y-1 text-ink-400">
<li>Walks every <code className="text-ink-200">RoomDataBlobs</code> row whose <code className="text-ink-200">Bytes</code> column still holds data.</li>
<li>Routes each blob through <code className="text-ink-200">BlobRouter.Route(BlobName)</code> to its canonical S3 (bucket, key).</li>
<li>Uploads to S3, HEAD-verifies the object exists, then clears the row's <code className="text-ink-200">Bytes</code>.</li>
<li>A row's bytes are only dropped from the DB after S3 confirms the same key — no data loss path.</li>
</ul>
<p className="font-medium text-ink-200 pt-2">When to run</p>
<ul className="list-disc list-inside space-y-1 text-ink-400">
<li>Once after the S3-only refactor deploy lands, to drain pre-cutover imports.</li>
<li>Idempotent — re-running picks up only rows whose <code className="text-ink-200">Bytes</code> hasn't been cleared yet.</li>
<li>Failed rows leave their DB bytes untouched and get retried on the next run.</li>
</ul>
</div>
</div>
)}
<Confirm
open={pendingMode === 'dry'}
onClose={() => setPendingMode(null)}
title="Run dry-run backfill"
body={<>Walks every row, logs the (bucket, key) it WOULD upload to, but doesn't write to S3 or clear the DB. Safe to run anytime — useful to verify the routing before a real run.</>}
confirmLabel="Start dry-run"
onConfirm={() => start(true)}
/>
<Confirm
open={pendingMode === 'real'}
onClose={() => setPendingMode(null)}
title="Run live backfill"
body={<>Uploads every row's bytes to S3 and clears the DB Bytes column for each row after HEAD-verifies the S3 object. Safe — re-runs are idempotent, and failures leave bytes intact in the DB. Continue?</>}
confirmLabel="Start backfill"
onConfirm={() => start(false)}
/>
</div>
);
}
function Counter({ label, value, tone }: { label: string; value: string; tone?: 'success' | 'danger' }) {
const color = tone === 'success' ? 'text-success'
: tone === 'danger' ? 'text-danger'
: 'text-ink-50';
return (
<div className="rounded border border-ink-800 bg-ink-900/60 px-3 py-2">
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className={`text-sm font-semibold tabular-nums ${color}`}>{value}</div>
</div>
);
}
+678
View File
@@ -0,0 +1,678 @@
import { useMemo, useState } from 'react';
import { api } from '../lib/api';
import type { StoreItem, StorefrontDefinition } 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 { useToast } from '../components/Toast';
import { Confirm } from '../components/Confirm';
import { currencyName, num, relativeTime } from '../lib/format';
import { Plus, RefreshCw, Search, Trash } from '../components/Icons';
// Storefronts the SPA can target when /admin/v1/storefronts hasn't
// responded yet. Mirrors StoreService.GetStorefrontDefinitions on the
// server side — replaced by the live response as soon as it arrives.
const FALLBACK_STOREFRONTS: StorefrontDefinition[] = [
{ key: 'main', storefrontType: null, displayName: 'Main watch catalog', scope: 'watch' },
{ key: 'watch', storefrontType: 3, displayName: 'Watch gift-drop shelf', scope: 'watch' },
{ key: 'all', storefrontType: null, displayName: 'All storefront shelves', scope: 'shared' },
{ key: 'rro', storefrontType: null, displayName: 'All RRO and Rec Center shelves', scope: 'shared' },
{ key: 'giftdrop:1', storefrontType: 1, displayName: 'Laser Tag', scope: 'room' },
{ key: 'giftdrop:2', storefrontType: 2, displayName: 'Rec Center', scope: 'room' },
{ key: 'giftdrop:100', storefrontType: 100, displayName: 'Quest - Lost Skulls', scope: 'room' },
{ key: 'giftdrop:101', storefrontType: 101, displayName: 'Quest - Dracula', scope: 'room' },
{ key: 'giftdrop:102', storefrontType: 102, displayName: 'Quest - Golden Trophy', scope: 'room' },
{ key: 'giftdrop:103', storefrontType: 103, displayName: 'Quest - Crimson Cauldron', scope: 'room' },
{ key: 'giftdrop:200', storefrontType: 200, displayName: 'Rec Royale', scope: 'room' },
{ key: 'giftdrop:300', storefrontType: 300, displayName: 'Cafe', scope: 'room' },
{ key: 'giftdrop:400', storefrontType: 400, displayName: 'Paintball', scope: 'room' },
{ key: 'giftdrop:401', storefrontType: 401, displayName: 'Paintball - River', scope: 'room' },
{ key: 'giftdrop:402', storefrontType: 402, displayName: 'Paintball - Homestead', scope: 'room' },
{ key: 'giftdrop:403', storefrontType: 403, displayName: 'Paintball - Quarry', scope: 'room' },
{ key: 'giftdrop:404', storefrontType: 404, displayName: 'Paintball - Clear Cut', scope: 'room' },
{ key: 'giftdrop:405', storefrontType: 405, displayName: 'Paintball - Spillway', scope: 'room' },
{ key: 'giftdrop:406', storefrontType: 406, displayName: 'Paintball - Sunset Drive-In', scope: 'room' },
{ key: 'giftdrop:500', storefrontType: 500, displayName: 'Bowling', scope: 'room' },
{ key: 'giftdrop:600', storefrontType: 600, displayName: 'Stunt Runner', scope: 'room' },
{ key: 'giftdrop:700', storefrontType: 700, displayName: 'Dorm Mirror', scope: 'room' },
{ key: 'season:1', storefrontType: null, displayName: 'Season 1', scope: 'season' },
];
const CATEGORIES = ['head', 'torso', 'legs', 'feet', 'accessory', 'hair', 'face', 'consumable', 'roomtemplate'];
// Sort options for the catalog list. `dir` keeps the comparator direction
// out of the sort dropdown labels so "Newest first" reads naturally.
type SortKey = 'name-asc' | 'name-desc' | 'price-asc' | 'price-desc' | 'updated-desc' | 'created-desc';
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: 'name-asc', label: 'Name (A → Z)' },
{ key: 'name-desc', label: 'Name (Z → A)' },
{ key: 'price-asc', label: 'Price (low → high)' },
{ key: 'price-desc', label: 'Price (high → low)' },
{ key: 'updated-desc', label: 'Recently updated' },
{ key: 'created-desc', label: 'Recently created' },
];
type ViewMode = 'cards' | 'table';
export function Store() {
const [storefront, setStorefront] = useState('');
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortKey>('updated-desc');
const [view, setView] = useState<ViewMode>('cards');
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive' | 'limited' | 'expired'>('all');
const [selected, setSelected] = useState<Set<number>>(new Set());
const [editing, setEditing] = useState<StoreItem | null | 'new'>(null);
const [pendingDelete, setPendingDelete] = useState<StoreItem | null>(null);
const [pendingBulk, setPendingBulk] = useState<null | { action: 'delete' | 'activate' | 'deactivate'; count: number }>(null);
const [bulkBusy, setBulkBusy] = useState(false);
const toast = useToast();
// Server-side pagination is overkill for this dataset (low hundreds
// of rows typically); pull a generous slice and filter / sort in the
// browser so the storefront/category dropdowns don't trigger network
// round-trips on every change.
const qs = new URLSearchParams();
qs.set('take', '500');
const { data, loading, error, refresh } = useApi<StoreItem[]>(`/storeitems?${qs}`);
const { data: storefrontData } = useApi<StorefrontDefinition[]>('/storefronts');
const storefronts = storefrontData ?? FALLBACK_STOREFRONTS;
// Apply every filter (search / storefront / category / activeFilter)
// then sort. Done in one memo so a single state change re-renders
// once instead of cascading through multiple useMemos.
const items = useMemo(() => {
if (!data) return [] as StoreItem[];
const now = Date.now();
const q = search.trim().toLowerCase();
const filtered = data.filter(it => {
if (storefront && it.storefront !== storefront) return false;
if (category && it.category !== category) return false;
if (q && !it.slug.toLowerCase().includes(q) && !it.displayName.toLowerCase().includes(q)) return false;
if (activeFilter === 'active' && !it.isActive) return false;
if (activeFilter === 'inactive' && it.isActive) return false;
if (activeFilter === 'limited' && !it.isLimitedTime) return false;
if (activeFilter === 'expired') {
if (!it.availableUntil) return false;
if (Date.parse(it.availableUntil) > now) return false;
}
return true;
});
const cmp = (a: StoreItem, b: StoreItem) => {
switch (sort) {
case 'name-asc': return a.displayName.localeCompare(b.displayName);
case 'name-desc': return b.displayName.localeCompare(a.displayName);
case 'price-asc': return a.price - b.price;
case 'price-desc': return b.price - a.price;
case 'updated-desc': return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
case 'created-desc': return Date.parse(b.createdAt) - Date.parse(a.createdAt);
}
};
return [...filtered].sort(cmp);
}, [data, storefront, category, search, sort, activeFilter]);
// Bulk-selection helpers — keep the Set immutable so React picks up
// the change without us tracking version numbers.
const toggleSelected = (id: number) => setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const allVisibleSelected = items.length > 0 && items.every(it => selected.has(it.id));
const selectAllVisible = () => setSelected(prev => {
if (allVisibleSelected) {
const next = new Set(prev);
for (const it of items) next.delete(it.id);
return next;
}
const next = new Set(prev);
for (const it of items) next.add(it.id);
return next;
});
const clearSelection = () => setSelected(new Set());
const del = async () => {
if (!pendingDelete) return;
try {
await api(`/storeitems/${pendingDelete.id}`, { method: 'DELETE' });
toast.push('Item deleted', 'success');
setPendingDelete(null);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
// Bulk action — sequential rather than Promise.all so the server
// doesn't get hit with 50 simultaneous writes (and so a partial
// failure tells us how far we got). Selected ids that aren't in the
// current `data` array (e.g. removed while the user was selecting)
// are skipped without error.
const runBulk = async () => {
if (!pendingBulk || !data) return;
setBulkBusy(true);
let ok = 0;
let failed = 0;
try {
const targets = data.filter(it => selected.has(it.id));
for (const it of targets) {
try {
if (pendingBulk.action === 'delete') {
await api(`/storeitems/${it.id}`, { method: 'DELETE' });
} else {
await api(`/storeitems/${it.id}`, {
method: 'PUT',
body: { IsActive: pendingBulk.action === 'activate' },
});
}
ok++;
} catch { failed++; }
}
const verb = pendingBulk.action === 'delete' ? 'deleted'
: pendingBulk.action === 'activate' ? 'activated'
: 'deactivated';
toast.push(`${verb} ${ok}/${ok + failed}`, failed > 0 ? 'error' : 'success');
clearSelection();
setPendingBulk(null);
refresh();
} finally {
setBulkBusy(false);
}
};
return (
<div>
<PageHeader
title="Store catalog"
blurb="Every row in StoreItemEntity across every storefront. Live items prefer IsActive=false (toggle from the card or bulk bar); hard delete is here for test cleanup."
actions={<>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
<button onClick={() => setEditing('new')} className="btn-primary text-xs">
<Plus /> New item
</button>
</>}
/>
{/* Filter / sort bar — kept on a single card so the controls share
visual weight and the count + view-toggle anchor the right edge. */}
<div className="card !p-3 mb-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[180px] max-w-md">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-400" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search slug or display name…"
className="input pl-8 w-full"
/>
</div>
<select value={storefront} onChange={e => setStorefront(e.target.value)} className="input max-w-[200px]">
<option value="">All storefronts</option>
{storefronts.map(s => <option key={s.key} value={s.key}>{storefrontLabel(s)}</option>)}
</select>
<select value={category} onChange={e => setCategory(e.target.value)} className="input max-w-[160px]">
<option value="">All categories</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select value={sort} onChange={e => setSort(e.target.value as SortKey)} className="input max-w-[180px]">
{SORT_OPTIONS.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
<div className="ml-auto inline-flex rounded-md border border-ink-800 bg-ink-900/40 p-0.5 text-[11px]">
<button
onClick={() => setView('cards')}
className={`px-2 py-1 rounded ${view === 'cards' ? 'bg-ink-800 text-ink-50' : 'text-ink-400 hover:text-ink-200'}`}
>Cards</button>
<button
onClick={() => setView('table')}
className={`px-2 py-1 rounded ${view === 'table' ? 'bg-ink-800 text-ink-50' : 'text-ink-400 hover:text-ink-200'}`}
>Table</button>
</div>
</div>
{/* Status chips — single-select. Stays on its own row so labels
wrap predictably on narrow screens. */}
<div className="flex flex-wrap items-center gap-2 text-[11px]">
{(['all', 'active', 'inactive', 'limited', 'expired'] as const).map(f => (
<button
key={f}
onClick={() => setActiveFilter(f)}
className={`rounded-full border px-2.5 py-1 transition-colors ${
activeFilter === f
? 'border-brand-500/60 bg-brand-500/15 text-brand-100'
: 'border-ink-800 bg-ink-900/40 text-ink-400 hover:border-ink-700 hover:text-ink-200'
}`}
>
{f === 'all' ? 'All' :
f === 'active' ? 'Active only' :
f === 'inactive' ? 'Inactive only' :
f === 'limited' ? 'Limited time' :
'Expired'}
</button>
))}
<div className="ml-auto text-xs text-ink-400">
{data ? `${items.length} of ${data.length} items` : ''}
</div>
</div>
</div>
{/* Bulk action bar — only shows when there's a selection. Floats
above the grid as a sticky-feeling banner. */}
{selected.size > 0 && (
<div className="card !p-2 mb-4 border-brand-500/40 bg-brand-500/5 flex flex-wrap items-center gap-2">
<span className="text-sm text-ink-100">
<span className="font-semibold text-brand-100">{selected.size}</span> selected
</span>
<button onClick={selectAllVisible} className="btn-ghost text-xs">
{allVisibleSelected ? 'Deselect visible' : 'Select all visible'}
</button>
<div className="ml-auto flex gap-1">
<button
onClick={() => setPendingBulk({ action: 'activate', count: selected.size })}
className="btn-secondary text-xs"
disabled={bulkBusy}
>Activate</button>
<button
onClick={() => setPendingBulk({ action: 'deactivate', count: selected.size })}
className="btn-secondary text-xs"
disabled={bulkBusy}
>Deactivate</button>
<button
onClick={() => setPendingBulk({ action: 'delete', count: selected.size })}
className="btn-danger text-xs"
disabled={bulkBusy}
><Trash /> Delete</button>
<button onClick={clearSelection} className="btn-ghost text-xs">Clear</button>
</div>
</div>
)}
{error && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{error}</div>}
{!loading && items.length === 0 && (
<Empty title={data && data.length === 0 ? 'No items in the catalog' : 'No items match the filters'} />
)}
{items.length > 0 && view === 'cards' && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{items.map(it => (
<StoreCard
key={it.id}
item={it}
selected={selected.has(it.id)}
onToggleSelected={() => toggleSelected(it.id)}
onEdit={() => setEditing(it)}
onDelete={() => setPendingDelete(it)}
/>
))}
</div>
)}
{items.length > 0 && view === 'table' && (
<div className="card overflow-hidden">
<div className="table-scroll">
<table className="w-full text-sm min-w-[820px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/50 border-b border-ink-800">
<tr>
<th className="w-10 px-3 py-2.5">
<input
type="checkbox"
checked={allVisibleSelected}
onChange={selectAllVisible}
className="size-4 accent-brand-500"
aria-label="Select all visible"
/>
</th>
<th className="w-14 px-2 py-2.5" />
<th className="text-left font-medium px-2 py-2.5">Item</th>
<th className="text-left font-medium px-3 py-2.5">Storefront</th>
<th className="text-left font-medium px-3 py-2.5">Category</th>
<th className="text-right font-medium px-3 py-2.5">Price</th>
<th className="text-left font-medium px-3 py-2.5">State</th>
<th className="w-32 px-3 py-2.5" />
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{items.map(it => (
<tr key={it.id} className={`table-row-hover ${selected.has(it.id) ? 'bg-brand-500/5' : ''}`}>
<td className="px-3 py-2">
<input
type="checkbox"
checked={selected.has(it.id)}
onChange={() => toggleSelected(it.id)}
className="size-4 accent-brand-500"
/>
</td>
<td className="px-2 py-2"><Thumb imageName={it.imageName} size={36} /></td>
<td className="px-2 py-2">
<div className="text-ink-50">{it.displayName}</div>
<div className="text-[11px] font-mono text-ink-500">{it.slug}</div>
</td>
<td className="px-3 py-2 text-ink-200 text-xs">{it.storefront}</td>
<td className="px-3 py-2 text-ink-200 text-xs">{it.category}</td>
<td className="px-3 py-2 text-right tabular-nums text-ink-100">
{num(it.price)} <span className="text-ink-400 text-xs">{currencyName(it.currencyType)}</span>
</td>
<td className="px-3 py-2">
<ItemBadges item={it} />
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<button onClick={() => setEditing(it)} className="btn-ghost text-xs">Edit</button>
<button onClick={() => setPendingDelete(it)} className="btn-ghost text-xs text-danger">
<Trash />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{editing && (
<StoreItemForm
item={editing === 'new' ? null : editing}
storefronts={storefronts}
onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); refresh(); }}
/>
)}
<Confirm
open={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
title="Delete store item"
body={<>Hard-delete <span className="font-medium text-ink-50">{pendingDelete?.displayName}</span>? Prefer toggling IsActive=false for live items.</>}
confirmLabel="Delete"
destructive
onConfirm={del}
/>
<Confirm
open={pendingBulk !== null}
onClose={() => pendingBulk && !bulkBusy && setPendingBulk(null)}
title={
pendingBulk?.action === 'delete' ? `Delete ${pendingBulk.count} items` :
pendingBulk?.action === 'activate' ? `Activate ${pendingBulk?.count} items` :
`Deactivate ${pendingBulk?.count} items`
}
body={pendingBulk?.action === 'delete'
? <>Hard-delete <span className="font-medium text-ink-50">{pendingBulk.count}</span> selected items? This cannot be undone.</>
: <>Toggle <span className="font-medium text-ink-50">{pendingBulk?.count}</span> items to {pendingBulk?.action === 'activate' ? 'active' : 'inactive'}?</>}
confirmLabel={bulkBusy ? 'Working…' : pendingBulk?.action === 'delete' ? 'Delete all' : 'Apply'}
destructive={pendingBulk?.action === 'delete'}
onConfirm={runBulk}
/>
</div>
);
}
// ── Cards / table cells ──────────────────────────────────────────────
function StoreCard({
item, selected, onToggleSelected, onEdit, onDelete,
}: {
item: StoreItem;
selected: boolean;
onToggleSelected: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div
className={`card !p-0 group relative flex flex-col overflow-hidden ${
selected ? 'ring-2 ring-brand-500/60 border-brand-500/30' : ''
}`}
>
{/* Selection checkbox — sits over the thumbnail. Only fully opaque
when the item is selected OR the user is hovering the card. */}
<label className={`absolute left-2 top-2 z-10 flex items-center justify-center rounded bg-ink-950/70 backdrop-blur-sm size-6 cursor-pointer transition-opacity ${selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
<input
type="checkbox"
checked={selected}
onChange={onToggleSelected}
className="size-4 accent-brand-500"
/>
</label>
{/* Active/limited/expired badges — top-right of the thumbnail. */}
<div className="absolute right-2 top-2 z-10 flex flex-col gap-1 items-end">
<ItemBadges item={item} />
</div>
<button
onClick={onEdit}
className="block w-full aspect-square bg-ink-950/40 overflow-hidden focus:outline-none"
title="Edit"
>
<Thumb imageName={item.imageName} size={256} fill />
</button>
<div className="px-2.5 py-2 flex flex-col gap-1">
<div className="text-sm text-ink-50 truncate" title={item.displayName}>{item.displayName}</div>
<div className="text-[10px] font-mono text-ink-500 truncate" title={item.slug}>{item.slug}</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs tabular-nums text-ink-100">
{num(item.price)} <span className="text-ink-400">{currencyName(item.currencyType)}</span>
</span>
<div className="flex items-center gap-1">
<button onClick={onEdit} className="btn-ghost text-[11px] py-0.5 px-1.5">Edit</button>
<button onClick={onDelete} className="btn-ghost text-[11px] py-0.5 px-1.5 text-danger" title="Delete">
<Trash />
</button>
</div>
</div>
<div className="text-[10px] text-ink-500 truncate">
{item.storefront} · {item.category}
</div>
</div>
</div>
);
}
function ItemBadges({ item }: { item: StoreItem }) {
const expired = item.availableUntil && Date.parse(item.availableUntil) < Date.now();
return (
<div className="flex flex-wrap gap-1">
{item.isActive ? <span className="badge-online">Active</span> : <span className="badge-neutral">Inactive</span>}
{item.isLimitedTime && <span className="badge-junior">Limited</span>}
{expired && <span className="badge-banned">Expired</span>}
</div>
);
}
// ── Thumbnail ────────────────────────────────────────────────────────
// Pulls the image from img.* with the same signed URL the watch uses
// (sig=p1, optional ?width hint). Falls back to a placeholder tile
// showing the filename when the image is missing or 404s — that's
// surprisingly common with imported rooms whose CDN bytes never made
// the trip.
function Thumb({ imageName, size, fill }: { imageName: string | null; size: number; fill?: boolean }) {
const [errored, setErrored] = useState(false);
const url = imageName && !errored ? profileImageUrl(imageName, Math.max(size, 128)) : null;
if (!url) {
return (
<div
className={`${fill ? 'w-full h-full' : ''} flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-950 text-[10px] text-ink-500 px-2 text-center`}
style={fill ? undefined : { width: size, height: size }}
title={imageName ?? 'no image'}
>
<span className="font-mono truncate">{imageName || 'no image'}</span>
</div>
);
}
return (
<img
src={url}
onError={() => setErrored(true)}
alt={imageName ?? ''}
loading="lazy"
className={`${fill ? 'w-full h-full object-cover' : ''} bg-ink-950`}
style={fill ? undefined : { width: size, height: size }}
/>
);
}
// ── Edit / create modal ──────────────────────────────────────────────
function StoreItemForm({
item, storefronts, onClose, onSaved,
}: {
item: StoreItem | null;
storefronts: StorefrontDefinition[];
onClose: () => void;
onSaved: () => void;
}) {
const isNew = item === null;
const [form, setForm] = useState({
slug: item?.slug ?? '',
displayName: item?.displayName ?? '',
description: item?.description ?? '',
storefront: item?.storefront ?? 'main',
category: item?.category ?? 'accessory',
imageName: item?.imageName ?? '',
currencyType: item?.currencyType ?? 2,
price: item?.price ?? 100,
isActive: item?.isActive ?? true,
isLimitedTime: item?.isLimitedTime ?? false,
availableUntil: item?.availableUntil ?? '',
});
const [busy, setBusy] = useState(false);
const toast = useToast();
const save = async () => {
setBusy(true);
try {
const body = {
Slug: form.slug.trim(),
DisplayName: form.displayName.trim(),
Description: form.description.trim(),
Storefront: form.storefront,
Category: form.category,
ImageName: form.imageName.trim(),
CurrencyType: form.currencyType,
Price: form.price,
IsActive: form.isActive,
IsLimitedTime: form.isLimitedTime,
AvailableUntil: form.availableUntil || null,
};
if (isNew) {
await api('/storeitems', { method: 'POST', body });
} else {
await api(`/storeitems/${item!.id}`, { method: 'PUT', body });
}
toast.push(isNew ? 'Item created' : 'Item updated', 'success');
onSaved();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
return (
<Modal
title={isNew ? 'New store item' : `Edit ${item!.slug}`}
open
onClose={onClose}
size="lg"
footer={<>
<button onClick={onClose} className="btn-ghost text-xs" disabled={busy}>Cancel</button>
<button onClick={save} disabled={busy || !form.slug.trim() || !form.displayName.trim()} className="btn-primary text-xs">
{busy ? 'Saving…' : isNew ? 'Create item' : 'Save changes'}
</button>
</>}
>
<div className="grid grid-cols-1 md:grid-cols-[160px_1fr] gap-4">
{/* Live preview anchored next to the form — pulls the image
using the same code path the catalog grid uses, so any
"no image" or 404 the admin sees in the cards is reproduced
here while editing. */}
<div className="space-y-2">
<div className="label">Preview</div>
<div className="aspect-square rounded-lg overflow-hidden bg-ink-950/40 border border-ink-800">
<Thumb imageName={form.imageName.trim() || null} size={256} fill />
</div>
<div className="text-[10px] text-ink-500 font-mono break-all">{form.imageName || 'no imageName set'}</div>
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Slug (unique)">
<input value={form.slug} onChange={e => setForm({ ...form, slug: e.target.value })} disabled={!isNew} className="input font-mono text-xs" />
</Field>
<Field label="Display name">
<input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} className="input" />
</Field>
</div>
<Field label="Description">
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={2} className="input" />
</Field>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Field label="Storefront">
<select value={form.storefront} onChange={e => setForm({ ...form, storefront: e.target.value })} className="input">
{!storefronts.some(s => s.key === form.storefront) && (
<option value={form.storefront}>{form.storefront}</option>
)}
{storefronts.map(s => <option key={s.key} value={s.key}>{storefrontLabel(s)}</option>)}
</select>
</Field>
<Field label="Category">
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} className="input">
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</Field>
<Field label="Image name (filename only)">
<input value={form.imageName} onChange={e => setForm({ ...form, imageName: e.target.value })} className="input font-mono text-xs" />
</Field>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Field label="Currency">
<select value={form.currencyType} onChange={e => setForm({ ...form, currencyType: parseInt(e.target.value) })} className="input">
<option value={1}>Tokens</option>
<option value={2}>Coins</option>
</select>
</Field>
<Field label="Price">
<input type="number" min={0} value={form.price} onChange={e => setForm({ ...form, price: parseInt(e.target.value || '0') })} className="input" />
</Field>
<Field label="Available until (ISO)">
<input type="datetime-local" value={form.availableUntil ? form.availableUntil.slice(0, 16) : ''} onChange={e => setForm({ ...form, availableUntil: e.target.value })} className="input" />
</Field>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-ink-200">
<input type="checkbox" checked={form.isActive} onChange={e => setForm({ ...form, isActive: e.target.checked })} className="size-4 accent-brand-500" />
Active
</label>
<label className="flex items-center gap-2 text-sm text-ink-200">
<input type="checkbox" checked={form.isLimitedTime} onChange={e => setForm({ ...form, isLimitedTime: e.target.checked })} className="size-4 accent-brand-500" />
Limited time
</label>
</div>
{!isNew && (
<div className="text-xs text-ink-500">
Created {relativeTime(item!.createdAt)} · Updated {relativeTime(item!.updatedAt)}
</div>
)}
</div>
</div>
</Modal>
);
}
function storefrontLabel(s: StorefrontDefinition) {
return `${s.displayName} (${s.key})`;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="label">{label}</span>
{children}
</label>
);
}
@@ -0,0 +1,15 @@
// Placeholder used by routes whose page hasn't been built yet so the
// router/nav is wireable today and pages can land incrementally without
// breaking links.
export function Stub({ title, blurb }: { title: string; blurb?: string }) {
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">{title}</h1>
<div className="card p-6 text-sm text-ink-300">
<div className="text-ink-200 mb-1">Coming soon.</div>
{blurb && <p className="text-ink-400">{blurb}</p>}
</div>
</div>
);
}