'use client'; import { useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request'; import { clearAdminRoomRequest, newAdminRoomRequest, readAdminRoomRequest, retainAdminRoomRequest, saveAdminRoomRequest } from '@/lib/admin-room-control-client'; import type { AdminRoomControlInput, RoomControl } from '@/lib/room-control'; type Props = { accountId: string; code: string; canManage: boolean; onClose: () => void; onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void; }; const message = (error: unknown) => error instanceof Error ? error.message : 'The room settings could not be loaded.'; function controlResponse(value: RoomControl): RoomControl { if (!value || typeof value.paused !== 'boolean' || typeof value.joinLocked !== 'boolean' || !Number.isSafeInteger(value.revision) || value.revision < 0 || !(value.updatedAt === null || Number.isSafeInteger(value.updatedAt))) throw new Error('The server returned unreadable room settings.'); return value; } export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) { const [control, setControl] = useState(null); const [paused, setPaused] = useState(false); const [joinLocked, setJoinLocked] = useState(false); const [reason, setReason] = useState(''); const [confirmed, setConfirmed] = useState(false); const [pending, setPending] = useState(null); const [storageProblem, setStorageProblem] = useState(false); const [notice, setNotice] = useState(''); const [busy, setBusy] = useState(false); const [initialRequestPending, setInitialRequestPending] = useState(false); const [loading, setLoading] = useState(true); const alive = useRef(false); const title = useRef(null); const inFlight = useRef(false); const url = `/api/admin/rooms/${code}/control`; useEffect(() => { alive.current = true; title.current?.focus(); let canceled = false; try { const saved = readAdminRoomRequest(window.sessionStorage, accountId, code); // oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR. setPending(saved); if (saved) setNotice('A saved request needs confirmation. Review the current settings, then retry the same request.'); } catch (error) { setStorageProblem(true); setNotice(message(error)); } requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }) .then(result => { const current = controlResponse(result); if (!canceled) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); } }) .catch(error => { if (!canceled) { setNotice(message(error)); if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); } }) .finally(() => { if (!canceled) setLoading(false); }); return () => { canceled = true; alive.current = false; }; }, [accountId, code, url, onDenied]); async function refresh() { if (inFlight.current) return; inFlight.current = true; setBusy(true); onBusyChange(true); try { const current = controlResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })); if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); setConfirmed(false); } } catch (error) { if (alive.current) { setNotice(message(error)); if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); } } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } } async function submit(saved?: AdminRoomControlInput) { if (inFlight.current || !control || !canManage || storageProblem || (!saved && !confirmed)) return; let input: AdminRoomControlInput; try { input = saved ?? newAdminRoomRequest(control, paused, joinLocked, reason); saveAdminRoomRequest(window.sessionStorage, accountId, code, input); } catch (error) { setNotice(message(error)); return; } inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…'); try { const result = await requestJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), }); const current = controlResponse(result); if (typeof result.replayed !== 'boolean') throw new Error('The server did not confirm this operation.'); clearAdminRoomRequest(window.sessionStorage, accountId, code); if (alive.current) { setPending(null); setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); setConfirmed(false); setReason(''); setNotice(result.replayed ? 'The original request was already recorded. Current settings are shown below.' : 'Room settings saved.'); onUpdated(); } } catch (error) { const uncertain = requestMayHaveCompleted(error); let cleared = false; if (!retainAdminRoomRequest(error)) { try { clearAdminRoomRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep exact retry after storage failure. */ } } if (alive.current) { if (cleared) setPending(null); setConfirmed(false); setNotice(uncertain ? `${message(error)} The change may have been saved. Use Retry saved request to confirm; no new request has been sent.` : message(error)); if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); else { try { const current = controlResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })); if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); } } catch { /* Preserve the original outcome and retry instructions. */ } } } } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } } function discardUnreadableRecord() { if (!confirmed || busy) return; try { clearAdminRoomRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false); setNotice('Local retry record removed. This did not undo any server change. Review the current settings before continuing.'); } catch (error) { setNotice(message(error)); } } const changed = control && (control.paused !== paused || control.joinLocked !== joinLocked); return

Room {code}

{notice && {notice}} {loading ? Loading current room settings… : !control ? : <>

Current status: {control.closed ? 'Closed' : control.paused ? 'Paused' : 'Running'} · New joins {control.joinLocked ? 'locked' : 'open'}.

Pause stops player decisions, automatic effects and AI play. The pending game stays saved. Discussion, seat recovery and taking back control remain available.

A joining lock blocks new players. Existing players can reconnect or recover their seats.

{!canManage ?

Your viewer role can inspect settings. An owner or operator can change them.

: storageProblem ?

No new changes can be sent until this tab’s unreadable retry record is resolved. Removing it does not cancel a change already saved on the server.

: initialRequestPending ? null : pending ?

Saved request: {pending.paused ? 'pause' : 'resume'} room {code}; {pending.joinLocked ? 'lock' : 'open'} new joins.

Reason: {pending.reason}

The exact request is kept in this tab across refresh. Retrying confirms an already completed operation without applying it again.

: control.closed ?

Reopen this room before changing pause or joining settings. Existing settings are preserved while closed.

:
{ event.preventDefault(); void submit(); }}>

Use a short operational reason. Do not include credentials or private game information.

This affects every player in room {code}. Warn active players before interrupting their game. Resuming keeps the pending choices and restarts AI pacing.

} }
; }