import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { api } from '../lib/api'; import type { Player, RoomDetail, RoomInstance, RoomRoleGrant } from '../lib/types'; import { profileImageUrl } from '../lib/types'; import { useApi } from '../lib/useApi'; import { PageHeader } from '../components/PageHeader'; import { Empty } from '../components/Empty'; import { Modal } from '../components/Modal'; import { Confirm } from '../components/Confirm'; import { PlayerAvatar } from '../components/PlayerAvatar'; import { useToast } from '../components/Toast'; import { ArrowLeft, RefreshCw, Trash } from '../components/Icons'; // Unified per-room admin view. One screen replaces what used to be // scattered across Rooms / RR Originals / Instances / individual edit // modals: general info edit, owner / co-owner / mod / host // management, live photon instances, recent visitors, danger zone. type Tab = 'general' | 'roles' | 'instances' | 'leaderboards' | 'visitors' | 'danger'; const ROLE_LABEL: Record<0 | 1 | 2, string> = { 0: 'Co-owner', 1: 'Moderator', 2: 'Host', }; const ACCESSIBILITY: Record = { 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(`/rooms/${roomId}`); const [tab, setTab] = useState('general'); if (Number.isNaN(roomId) || roomId <= 0) { return (
); } return (
Back to rooms } /> {error &&
{error}
} {loading && !room &&
Loading…
} {room && ( <>
{(['general', 'roles', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => ( ))}
{tab === 'general' && } {tab === 'roles' && } {tab === 'instances' && } {tab === 'leaderboards' && } {tab === 'visitors' && } {tab === 'danger' && navigate('/rooms')} />} )}
); } // ── 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 (
{img ? {room.name} :
no image
}

{room.name}

{room.isDormRoom && Dorm} {!room.isDormRoom && room.isAGRoom && room.owner.id === 1 && RR Original} {!room.isDormRoom && room.owner.id !== 1 && Custom} {room.state === 1 && Archived}
#{room.id} · {ACCESSIBILITY[room.accessibility] ?? `Access ${room.accessibility}`} · {room.cloningAllowed ? 'Cloning allowed' : 'No cloning'}
{tags.length > 0 && (
{tags.map(t => #{t})}
)}
Owner: @{room.owner.username || `#${room.owner.id}`}
Created {new Date(room.createdAt).toLocaleDateString()}
Updated {new Date(room.updatedAt).toLocaleDateString()}
); } function StatPill({ label, value }: { label: string; value: number | string }) { return ( {label} {value} ); } // ── 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 [maxCapacity, setMaxCapacity] = useState(String(room.maxCapacity ?? 8)); const [busy, setBusy] = useState(false); // Re-sync local state if a refresh brings new server values in. useEffect(() => { setDescription(room.description); setAccessibility(room.accessibility); setCloningAllowed(room.cloningAllowed); setTagsCsv(room.tagsCsv); setHotScore(String(room.hotScore)); setImageName(room.imageName); setMaxCapacity(String(room.maxCapacity ?? 8)); }, [room.id, room.updatedAt]); const save = async () => { setBusy(true); try { const hot = Number(hotScore); const cap = parseInt(maxCapacity, 10); await api(`/rooms/${room.id}/props`, { method: 'POST', body: { Description: description, Accessibility: accessibility, CloningAllowed: cloningAllowed, TagsCsv: tagsCsv, HotScore: Number.isFinite(hot) ? hot : undefined, ImageName: imageName, MaxCapacity: Number.isFinite(cap) ? cap : undefined, }, }); toast.push('Saved', 'success'); onSaved(); } catch (e) { toast.push((e as Error).message, 'error'); } finally { setBusy(false); } }; return (