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('/rooms'); const [filter, setFilter] = useState(''); const [filterKind, setFilterKind] = useState<'all' | 'original' | 'custom' | 'dorm'>('all'); const [pendingDelete, setPendingDelete] = useState(null); const [pendingPurge, setPendingPurge] = useState(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 (
} />
setFilter(e.target.value)} placeholder="Filter by name…" className="input max-w-xs" />
{(['all', 'original', 'custom', 'dorm'] as const).map(k => ( ))}
{data ? `${filtered.length} / ${data.length}` : ''}
{error &&
{error}
} {data && filtered.length === 0 && } {filtered.length > 0 && (
{filtered.map(r => ( ))}
# Name Type Creator Blobs
{r.id} {/* 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. */} {r.name} {r.isDormRoom && Dorm} {/* "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) && RR Original} {isUserCustom(r) && Custom} {r.creatorPlayerId ? `#${r.creatorPlayerId}` : '—'} {r.blobCount} Manage
)}
setPendingDelete(null)} title="Archive room" body={<>Set {pendingDelete?.name} to State=1 (Archived)? The room will stop appearing in browse/search but stays restorable.} confirmLabel="Archive" destructive onConfirm={del} /> {pendingPurge && ( setPendingPurge(null)} onPurged={refresh} /> )}
); } // ── 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 ( } >

Hard-delete {room.name} (room #{room.id})? This drops the room row, every scene, every per-room .room blob, and the thumbnail. Shared HTR / PV image assets stay. The Name frees up so a re-import works.

Irreversible — no soft-archive fallback.

To confirm, type the room name {room.name} in BOTH inputs below. Case-sensitive.
); }