Files
recflare/apps/www/src/client/App.tsx
T
devin 66c09806f9 [www] beta room uploads (#34)
* WIP room uploads

* add beta warning
2026-08-15 13:14:40 -04:00

2190 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react'
import { Accessibility } from '@repo/domain/src/enums'
import { GAME_VERSION } from '@repo/domain/src/presence-db'
import { NotificationType } from '../../../notify/src/notification-types'
import { authFailure, authUnreachable } from '../auth-messages'
import {
DISCORD_INVITE,
DOWNLOAD_URL,
LICENSE_URL,
QUEST_DOWNLOAD_URL,
SOURCE_REPO,
} from '../links'
import type { ReactNode } from 'react'
/**
* The SPA calls the SAME endpoints the game does — `auth` for tokens and the password
* change, `accounts` for the profile, `api` for the photo feed, `notify` for the admin
* broadcasts — rather than proxying each one through `www`, exactly as rec.net's own
* site did. Those workers answer CORS for it (see their `withDefaultCors()`), and the
* access token lives here in the browser.
*
* `www` serves only two things of its own (see www.app.ts): the config below, and
* signup, which is Turnstile-gated and so cannot leave the server.
*/
/** Where each worker lives. From `/api/config`, never baked into this build. */
interface Hosts {
auth: string
accounts: string
api: string
img: string
notify: string
rooms: string
cdn: string
storage: string
}
/**
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
* keypair configured — web signup runs behind that bot check, so without it the endpoint
* is closed and the UI must not offer the form.
*/
interface SiteConfig {
signupEnabled: boolean
turnstileSiteKey: string | null
}
/** The private self DTO from `accounts` (`GET /account/me`). */
interface SelfAccount {
accountId: number
username: string
displayName: string
email: string | null
/**
* Username changes left on the account — each change spends one, and an account
* starts with one. Absent on an older self DTO, which reads as "unknown": the form
* stays usable and lets the server be the one to refuse.
*/
availableUsernameChanges?: number
}
/**
* One subroom, as `rooms` re-attaches them to every room read. A room is a container;
* the subrooms are the actual places players load into, each with its own accessibility
* and its own save history.
*/
interface SubRoom {
SubRoomId: number
Name: string
/** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */
Accessibility: number
IsSandbox: boolean
MaxPlayers: number
/** The subroom's scene-data key, served back by `cdn` under `room/`. */
DataBlob?: string
/**
* A save posted without `AutoPublish` waits here. Cleared when that save is
* published, so a non-null value means "edited since players last saw a change".
*/
StagedSubRoomDataSaveId: number | null
/**
* What players actually load. Null until the first publish — and a subroom without
* one silently loads nothing, which is worth surfacing to an owner who can't tell
* that apart from a broken room.
*/
CurrentSave: {
SubRoomDataSaveId: number
CreatedAt: string
Description: string
/**
* The scene-data key for the published save — what the client downloads to load
* the place, and the file worth keeping a copy of. `subRoomDataBlob()` resolves
* this one first, ahead of the subroom's own.
*/
DataBlob: string
} | null
}
/**
* One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what these pages draw.
* The worker serves the stored room blob verbatim — dozens of fields the game needs and
* the website doesn't — so only the ones read here are declared.
*/
interface OwnedRoom {
RoomId: number
Name: string
Description: string
/** A key on the `img` worker; a room with no image of its own gets the fallback. */
ImageName: string
/** The `Accessibility` ordinal, NOT the enum name — see ACCESSIBILITY_LABEL. */
Accessibility: number
CreatedAt: string
MaxPlayers: number
/** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */
CloningAllowed: boolean
SupportsScreens: boolean
SupportsWalkVR: boolean
SupportsTeleportVR: boolean
SupportsQuest2: boolean
SupportsMobile: boolean
SupportsJuniors: boolean
/** `Type` 0 is a tag the owner set, 2 one the server derived. */
Tags: Array<{ Tag: string; Type: number }>
SubRooms: SubRoom[]
/** Always present: the worker folds the live counters in on every read. */
Stats: {
CheerCount: number
FavoriteCount: number
VisitorCount: number
VisitCount: number
}
}
/**
* What an `Accessibility` ordinal is called on screen — rooms and subrooms both carry
* one. The two dev values are reachable (the game sets them), so they're named rather
* than left to fall through to the unknown case in `accessibilityLabel`.
*/
const ACCESSIBILITY_LABEL: Record<number, string> = {
[Accessibility.Private]: 'Private',
[Accessibility.Public]: 'Public',
[Accessibility.Unlisted]: 'Unlisted',
[Accessibility.Dev_only]: 'Dev only',
[Accessibility.Dev_Unlisted]: 'Dev unlisted',
}
/**
* RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in.
* NOT passed on signup: create_account treats an asserted platform as one to verify
* against Steam and rejects RecNet — the web signup is the (platform-less) password
* account path.
*/
const WEB_PLATFORM = '4'
/**
* The session's access token, in localStorage so a reload stays signed in.
*
* Readable by page JS, which the httpOnly cookie this replaced was not — that is the
* tradeoff that comes with the browser calling the workers itself, and it's the same
* posture the game client has. Nothing third-party runs on this origin except the
* Turnstile widget, which is Cloudflare's own.
*/
const TOKEN_KEY = 'rf_token'
let token: string | null = localStorage.getItem(TOKEN_KEY)
function setToken(next: string | null) {
token = next
if (next === null) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, next)
}
/**
* Filled in once `/api/config` lands, before any worker call is made — a module value
* rather than a prop threaded through every form, since the components that call a
* worker only render after the config resolves.
*/
let hosts: Hosts | null = null
/** The hostnames, once known. Throws rather than guessing a domain. */
function where(): Hosts {
if (hosts === null) throw new Error('Still starting up — please reload the page.')
return hosts
}
/**
* Roles that unlock the admin controls. Mirrors the notify worker's `ADMIN_ROLES` gate —
* this only decides whether to SHOW them; notify verifies the token on every call.
*/
const ADMIN_ROLES = new Set(['developer', 'moderator'])
/**
* Whether the session token carries an admin role. Decodes the `role` claim WITHOUT
* verifying it — a page holds no signing key, and faking one here only reveals buttons
* whose endpoints reject the same token. A malformed token reads as "not admin".
*/
function isAdmin(): boolean {
const payload = token?.split('.')[1]
if (!payload) return false
try {
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
const claims = JSON.parse(atob(padded)) as { role?: unknown }
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
} catch {
return false
}
}
/**
* An OAuth machine code (`invalid_grant`, `server_error`) rather than a sentence — a
* lower_snake_case word with no spaces. A worker that speaks OAuth puts one of these in
* `error`, where the readable reason is in `error_description`.
*/
const isErrorCode = (s: string) => /^[a-z][a-z\d]*(_[a-z\d]+)+$/.test(s)
/**
* The message worth showing for a refusal. `error` wins, since that's where a worker
* puts a sentence it wrote for the player — but NOT when it's a bare OAuth code, which
* tells nobody anything. Some refusals carry no body at all (accounts answers a
* malformed email with an empty 400), hence the last-resort line.
*/
function errorMessage(data: Record<string, unknown>, status: number): string {
const error = typeof data.error === 'string' ? data.error : ''
const description = typeof data.error_description === 'string' ? data.error_description : ''
return (
(error && !(isErrorCode(error) && description) && error) ||
description ||
error ||
`Request failed (${status})`
)
}
interface CallOptions {
method?: 'GET' | 'POST' | 'PUT'
/** Form fields — auth and accounts read their input with Hono's `parseBody()`. */
form?: Record<string, string>
/** A JSON body — what notify's internal endpoints take instead. */
json?: unknown
/**
* A multipart body — what `storage`'s `/upload` takes, since it carries a file. Passed
* to `fetch` as-is: the browser writes the `content-type` itself, because only it
* knows the boundary it generated.
*/
multipart?: FormData
/** Send the session token. */
authed?: boolean
/**
* What to say when the worker refuses with a 400 and NO body. Several accounts routes
* do exactly that (email, display name, bio), so without this the player reads
* "Request failed (400)" — the status, not the reason.
*/
refusal?: string
}
/** Call a worker. Returns the parsed body, or throws with something worth showing. */
async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
const headers: Record<string, string> = {}
if (opts.authed && token) headers.authorization = `Bearer ${token}`
let body: string | FormData | undefined
if (opts.form) {
headers['content-type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(opts.form).toString()
} else if (opts.json !== undefined) {
headers['content-type'] = 'application/json'
body = JSON.stringify(opts.json)
} else if (opts.multipart) {
// Deliberately no content-type: setting one would omit the boundary.
body = opts.multipart
}
const res = await fetch(url, {
method: opts.method ?? (body === undefined ? 'GET' : 'POST'),
headers,
body,
})
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
// Expired or revoked. Cleared here so no caller has to remember to.
if (res.status === 401 && opts.authed) {
setToken(null)
throw new Error('Your session has expired. Please sign in again.')
}
// Only when the body really is empty — a worker that did send a reason keeps it.
if (opts.refusal !== undefined && res.status === 400 && Object.keys(data).length === 0) {
throw new Error(opts.refusal)
}
throw new Error(errorMessage(data, res.status))
}
return data as T
}
/** The signed-in account, straight from `accounts`. */
const fetchMe = (): Promise<SelfAccount> =>
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
/**
* The caller's own rooms, from the `rooms` worker — the same list the game's "My Rooms"
* loads. `ownedby/me` rather than `createdby/me`: the dorm is auto-provisioned, not a
* room the player made, and it's the one room they can't do anything with from here.
*
* The worker deliberately does NOT filter on accessibility for this list, so a room that
* has never been published shows up — which is the point, since that's the one its owner
* is most likely to be looking for.
*
* Sorted newest-first here rather than upstream: the query has no ORDER BY (D1 hands
* back insertion order, which is not a promise), and the room someone just made is the
* one they came to see.
*/
async function fetchMyRooms(): Promise<OwnedRoom[]> {
const rooms = await call<OwnedRoom[]>(`${where().rooms}/rooms/ownedby/me`, { authed: true })
// A bare array is the contract; anything else is treated as "no rooms" rather than
// thrown, since `.sort` on a non-array would surface as an unreadable TypeError.
if (!Array.isArray(rooms)) return []
// ISO-8601 timestamps, so lexical order IS chronological order.
return [...rooms].sort((a, b) => (a.CreatedAt < b.CreatedAt ? 1 : -1))
}
/**
* The `UploadFileType` a room's scene data is posted under. `storage` maps this to the
* `room/` subfolder of the CDN bucket — the one prefix `cdn`'s `GET /room/:dataBlob`
* reads back, and so the only one a `DataBlob` key can point into.
*/
const FILE_TYPE_ROOM_SAVE = '1'
/**
* The game build this server targets, as `YYYY-MM-DD` — read from the same `GAME_VERSION`
* the auth token and presence carry rather than written out again here, so upgrading the
* client moves this line with it instead of leaving a stale date on the upload form.
*
* It's shown because a scene blob is only loadable by the build that wrote it (or older
* ones that understand it): a save taken out of a room built on a later version can fail
* outright, and nothing between here and the game says why.
*/
const CLIENT_BUILD_DATE = `${GAME_VERSION.slice(0, 4)}-${GAME_VERSION.slice(4, 6)}-${GAME_VERSION.slice(6, 8)}`
/**
* Upload a scene blob to `storage` and return the key it was stored under — the
* `<date>/<uuid>` name every `DataBlob` field holds.
*
* This is the same two-step the game does: the bytes go to `storage` first, and only its
* generated name is handed to `rooms`. Nothing about the file is inspected here — a room
* blob is an opaque Unity payload, and the server doesn't parse it either, so the only
* honest validation available is whether the game can load it afterwards.
*/
async function uploadRoomBlob(file: File): Promise<string> {
const form = new FormData()
form.set('FileType', FILE_TYPE_ROOM_SAVE)
form.set('File', file)
const { filename } = await call<{ filename?: string }>(`${where().storage}/upload`, {
method: 'POST',
multipart: form,
authed: true,
})
if (!filename) throw new Error('The storage worker accepted the file but returned no name.')
return filename
}
/**
* The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's
* `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a
* save whose hash doesn't describe its blob is worse than one carrying none.
*/
async function blobHash(file: File): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', await file.arrayBuffer()))
let binary = ''
for (const byte of digest) binary += String.fromCharCode(byte)
return btoa(binary)
}
/**
* Record a room save against one subroom, pointing it at an already-uploaded blob.
*
* `AutoPublish` decides whether players see it now or whether it waits on the room's
* publish step, exactly as it does for the game — the site doesn't get its own rule.
* The envelope answers HTTP 200 either way and puts the refusal in `error`, so success
* has to be read from the body rather than the status. `value.room` is the updated room,
* which the page re-renders from rather than re-fetching the whole list.
*/
async function saveSubRoomBlob(
roomId: number,
subRoomId: number,
input: { filename: string; hash: string; description: string; autoPublish: boolean }
): Promise<OwnedRoom> {
const res = await call<{
success?: boolean
error?: string | null
value?: { room?: OwnedRoom } | null
}>(`${where().rooms}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
method: 'POST',
authed: true,
json: {
SubRoomData: { Filename: input.filename, Hash: input.hash },
Description: input.description,
AutoPublish: input.autoPublish,
},
})
if (res.success !== true) {
throw new Error(res.error || 'The rooms worker refused the save.')
}
const room = res.value?.room
if (!room) throw new Error('The save was recorded but the room came back empty.')
return room
}
/**
* Sign in with auth's password grant, posted directly the way the game posts it. The
* account is resolved by `username` (case-insensitive) — web players sign in with their
* username, not the numeric account id.
*
* A refusal is translated through the table shared with the worker (see
* `auth-messages.ts`): auth's `error` is always a machine code, and the reason in
* `error_description` is written for an operator, not a player.
*/
async function signIn(username: string, password: string): Promise<void> {
const res = await fetch(`${where().auth}/connect/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'password',
username,
platform: WEB_PLATFORM,
password,
}).toString(),
}).catch(() => null)
if (res === null) throw new Error(authUnreachable('login'))
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
const code = typeof data.error === 'string' ? data.error : ''
const description = typeof data.error_description === 'string' ? data.error_description : ''
throw new Error(authFailure('login', res.status, code, description).message)
}
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
setToken(data.access_token)
}
/**
* Create an account — the one flow that goes through `www`, because it's gated by
* Turnstile and that check needs a secret key a page can't hold. www hands back auth's
* token response unchanged, so the session is established just as sign-in establishes it.
*/
async function signUp(password: string, turnstileToken: string): Promise<void> {
const data = await call<{ access_token?: string }>('/api/signup', {
json: { password, turnstileToken },
})
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('signup'))
setToken(data.access_token)
}
/**
* Change the username.
*
* `accounts` answers this one in its own envelope — `{ success, error, value }` at HTTP
* 200 even when it refused (taken name, no changes left) — so a 200 is not enough to
* call it done. The sentences it writes are already player-facing, so they're shown as-is.
*
* On success the SELF account is re-read rather than using the envelope's `value`: that
* is the PUBLIC DTO, and it carries no `availableUsernameChanges` — the very field this
* form needs to know whether another change is left.
*/
async function changeUsername(username: string): Promise<SelfAccount> {
const result = await call<{ error?: unknown }>(`${where().accounts}/account/me/username`, {
method: 'PUT',
form: { username },
authed: true,
})
const refusal = typeof result.error === 'string' ? result.error : ''
if (refusal !== '') throw new Error(refusal)
return fetchMe()
}
/**
* Set the account's email.
*
* The address is NOT checked here first. `accounts` validates it with `isemail`, which
* can't come along into the browser (it reaches for node's `util`, which vite stubs with
* a throwing Proxy in dev) — and a second, looser copy of the rule would only disagree
* with the real one. The server decides; this just names the refusal it answers with.
*/
const saveEmail = (email: string): Promise<unknown> =>
call(`${where().accounts}/account/me/email`, {
form: { email },
authed: true,
refusal: 'That email address looks wrong.',
})
/** Change the account's password. Lives on `auth`, not `accounts`. */
const changePassword = (oldPassword: string, newPassword: string): Promise<unknown> =>
call(`${where().auth}/account/me/changepassword`, {
form: { oldPassword, newPassword },
authed: true,
})
/**
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
* — so a session without the role is rejected there (403) even though the UI shows no
* button. The maintenance frame carries `Msg: { StartsInMinutes }`, matching the game
* client's ServerMaintenance handler.
*/
const broadcastMaintenance = (startsInMinutes: number): Promise<{ delivered?: number }> =>
call<{ delivered?: number }>(`${where().notify}/internal/broadcast`, {
json: {
notificationType: NotificationType.ServerMaintenance,
data: { StartsInMinutes: startsInMinutes },
},
authed: true,
})
const coachMessageAll = (messageContent: string): Promise<{ sent?: number }> =>
call<{ sent?: number }>(`${where().notify}/internal/coach-message-all`, {
json: { messageContent },
authed: true,
})
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
function useRouter() {
const [path, setPath] = useState(() => window.location.pathname)
useEffect(() => {
const onPop = () => setPath(window.location.pathname)
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [])
const navigate = useCallback((to: string) => {
if (to !== window.location.pathname) {
window.history.pushState(null, '', to)
window.scrollTo(0, 0)
}
setPath(to)
}, [])
return { path, navigate }
}
type Navigate = (to: string) => void
/** An in-app link that routes client-side instead of doing a full page load. */
function Link({
to,
navigate,
className,
children,
}: {
to: string
navigate: Navigate
className?: string
children: ReactNode
}) {
return (
<a
href={to}
className={className}
onClick={(e) => {
e.preventDefault()
navigate(to)
}}
>
{children}
</a>
)
}
/**
* The room id in `/rooms/<id>`, or null for any other path. Numeric rather than the
* room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone
* bookmarked would rot the moment they renamed the room.
*/
function roomIdFromPath(path: string): number | null {
const match = /^\/rooms\/(\d+)$/.exec(path)
return match ? Number.parseInt(match[1], 10) : null
}
export function App() {
// undefined = still checking the session; null = signed out.
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
// undefined until the config lands. Signup is treated as closed until told otherwise,
// so a slow (or failed) config fetch can't flash a form the server would refuse.
const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
const { path, navigate } = useRouter()
const roomId = roomIdFromPath(path)
useEffect(() => {
// Config first, and everything else after it: it carries the hostnames every other
// call needs. A config that doesn't land leaves the page signed out with signup
// closed rather than guessing where the workers are.
call<SiteConfig & { hosts: Hosts }>('/api/config')
.then(async ({ hosts: resolved, ...site }) => {
hosts = resolved
setConfig(site)
if (token === null) return setAccount(null)
// A stored token that `accounts` rejects is stale — `call` has already dropped
// it, so this just falls back to signed-out rather than surfacing an error.
await fetchMe()
.then(setAccount)
.catch(() => setAccount(null))
})
.catch(() => {
setConfig({ signupEnabled: false, turnstileSiteKey: null })
setAccount(null)
})
}, [])
// Nothing to tell a server: the access token is a stateless JWT, so dropping it here
// IS the sign-out. (The refresh token auth issues alongside it is never stored, so a
// closed session leaves nothing behind to redeem.)
const logout = useCallback(() => {
setToken(null)
setAccount(null)
navigate('/')
}, [navigate])
return (
<>
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
{path === '/login' || path === '/signup' ? (
// One page, two doors. `/signup` exists so the homepage's create-account link
// lands on that tab instead of dropping people on sign-in to find it — and so
// the URL is linkable. Unknown paths fall back to index.html (see the assets
// config in wrangler.jsonc), so a cold load of /signup reaches the SPA.
<LoginPage
account={account}
config={config}
initialTab={path === '/signup' ? 'signup' : 'login'}
navigate={navigate}
onAuthed={setAccount}
/>
) : path === '/account' ? (
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
) : roomId !== null ? (
<RoomPage account={account} roomId={roomId} navigate={navigate} />
) : (
<HomePage account={account} config={config} navigate={navigate} />
)}
<SiteFooter />
</>
)
}
/** Footer: where to go next, plus the affiliation disclaimer. */
function SiteFooter() {
return (
<footer className="footer">
<span>
<a href={LICENSE_URL} target="_blank" rel="noreferrer">
MIT licensed
</a>{' '}
made by fans, not affiliated with Rec Room Inc.
</span>
<nav>
{/* A real navigation, not a client-side route: /privacy is rendered by the
Worker (see src/privacy.ts) so it reads without JavaScript. */}
<a href="/privacy">Privacy</a>
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Discord
</a>
<a href={SOURCE_REPO} target="_blank" rel="noreferrer">
GitHub
</a>
</nav>
</footer>
)
}
/** Top nav: brand → home, plus a sign-in / my-account link for the session. */
function NavBar({
account,
path,
navigate,
onLogout,
}: {
account: SelfAccount | null | undefined
path: string
navigate: Navigate
onLogout: () => void
}) {
return (
<header className="nav">
<Link to="/" navigate={navigate} className="brand">
RecFlare
</Link>
<nav className="nav-links">
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Discord
</a>
{account === undefined ? null : account ? (
<>
<Link to="/account" navigate={navigate} className={path === '/account' ? 'active' : ''}>
My account
</Link>
<button className="linkish" onClick={onLogout}>
Sign out
</button>
</>
) : (
<Link to="/login" navigate={navigate} className={path === '/login' ? 'active' : ''}>
Sign in
</Link>
)}
</nav>
</header>
)
}
/**
* How many photos the hero asks the feed for. Explicit rather than left to the api's
* default, since the count is a design decision here: the stage rotates one photo every
* six seconds, so ten is a minute of it — long enough that a repeat visitor sees fresh
* photos, short enough that the arrows stay walkable and the payload stays small.
*/
const SLIDESHOW_TAKE = 10
/** A recent public image plus who took it and where. */
interface Slide {
url: string
username: string
roomName: string | null
}
/**
* Loads the public photo feed once. `slides === null` means still in flight.
*
* Waits for the config, since the feed is served by the `api` worker — the same public
* endpoint the game reads it from — and its hostname arrives with the config. Each entry
* names an image; the browsable URL for it is on the `img` worker.
*/
function useSlideshow(config: SiteConfig | undefined) {
const [slides, setSlides] = useState<Slide[] | null>(null)
const [error, setError] = useState('')
useEffect(() => {
if (config === undefined) return
type Feed = { Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }> }
// Wrapped in an async call rather than started directly, because `where()` THROWS
// when the config didn't land — synchronously, which straight out of an effect
// would take the page down instead of leaving an empty stage behind the fold.
void (async () => {
const h = where()
const d = await call<Feed>(`${h.api}/api/images/v1/slideshow?take=${SLIDESHOW_TAKE}`)
setSlides(
(d.Images ?? []).map((i) => ({
url: `${h.img}/${i.ImageName}`,
username: i.Username,
roomName: i.RoomName,
}))
)
})().catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [config])
return { slides, error }
}
/**
* Public homepage. The stage leads: photos players actually took, with the way in
* on top of them. Everything about how the thing is built sits below, for whoever
* scrolls looking for it.
*/
function HomePage({
account,
config,
navigate,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
navigate: Navigate
}) {
const feed = useSlideshow(config)
// The signup offer only makes sense to a signed-out visitor when the server would
// actually take one. `account === undefined` is still-checking, so it shows nothing
// rather than offering an account to someone who already has one.
const offerSignup = account === null && config?.signupEnabled === true
return (
<main>
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
<div className="shell home">
<About slides={feed.slides} error={feed.error} />
</div>
</main>
)
}
/**
* The hero: the headline and the way in on the left, a rotating in-game photo on the
* right. The photo is proof, never the payload — when the feed is slow or down the
* frame holds its space and the left half reads the same, so "Play now!" is reachable
* either way.
*/
function Stage({
slides,
offerSignup,
navigate,
}: {
slides: Slide[] | null
offerSignup: boolean
navigate: Navigate
}) {
const [idx, setIdx] = useState(0)
const count = slides?.length ?? 0
// A timeout keyed on the current slide rather than one long-lived interval: steering
// by hand re-arms it, so a photo you just picked gets its full six seconds.
useEffect(() => {
if (count < 2) return
const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000)
return () => clearTimeout(t)
}, [count, idx])
const slide = slides && slides.length > 0 ? slides[idx] : null
const step = (by: number) => setIdx((i) => (i + by + count) % count)
return (
<section className="stage">
<div className="stage-body">
{/* Deliberately doesn't name the game: this is a fan project, so the
trademark stays out of the headline and appears lower down, in
plain nominative use next to the disclaimer. */}
<h1 className="stage-title">
Play like it&apos;s <em>2023</em>.
</h1>
<p className="stage-lede">
The servers you remember, rebuilt and running free, open source, and up right now.
</p>
<div className="stage-actions">
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
Download for PC
</a>
<a className="cta" href={QUEST_DOWNLOAD_URL} target="_blank" rel="noreferrer">
Download for Quest
</a>
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Join the Discord
</a>
</div>
{/* A line rather than a fourth button: the download is the point of this page,
and launching the game makes an account by itself — signing up here is the
way in for someone who wants one first. Hidden entirely when signup is
closed, matching /login, which hides its create-account tab the same way. */}
{offerSignup && (
<p className="stage-alt">
New here?{' '}
<Link to="/signup" navigate={navigate}>
Create an account
</Link>
</p>
)}
</div>
<div className="stage-show">
<div className="stage-frame">
{slide && (
<img
className="stage-photo"
key={slide.url}
src={slide.url}
alt={`Photo taken in game by ${slide.username}`}
/>
)}
</div>
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
<div className="stage-foot">
{slide && (
<span className="credit">
Photo by @{slide.username}
{slide.roomName && ` in ${slide.roomName}`}
</span>
)}
{/* Arrows and a count, not a dot per photo: a dot each is wide enough to
shove the headline's half of the split off the page, and it would have
to be rebuilt the moment SLIDESHOW_TAKE grows. */}
{count > 1 && (
<span className="steer">
<button onClick={() => step(-1)} aria-label="Previous photo">
<Chevron />
</button>
<span className="count">
{idx + 1} / {count}
</span>
<button onClick={() => step(1)} aria-label="Next photo">
<Chevron next />
</button>
</span>
)}
</div>
</div>
</section>
)
}
/** The slideshow's back/forward mark. Decorative — the buttons carry the label. */
function Chevron({ next }: { next?: boolean }) {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
d={next ? 'M9 5l7 7-7 7' : 'M15 5l-7 7 7 7'}
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/** What RecFlare is, under the fold, for whoever wants it. */
function About({ slides, error }: { slides: Slide[] | null; error: string }) {
// The feed answering is proof the server replied, so the indicator can't claim
// the server is up when it isn't.
const state = slides !== null ? 'online' : error ? 'down' : 'checking'
return (
<section className="about">
<div>
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
<p className="about-lede">
A free fan project, made by players who missed it. Aiming to be{' '}
<strong>feature-complete</strong> and infinitely scalable {' '}
<strong>architected for the cloud</strong>, no gatekeeping, no basement server.
</p>
</div>
<div className="about-side">
<div className="about-links">
<a className="cta ghost" href={SOURCE_REPO} target="_blank" rel="noreferrer">
View the source
</a>
</div>
<div className="status-block">
<p className={`status ${state}`}>
<span className="dot" />
{state === 'online'
? 'Servers are up'
: state === 'down'
? "Can't reach the servers"
: 'Checking…'}
</p>
{/* Only when it's actually up: when it isn't, people want the status, not the joke. */}
{state === 'online' && <p className="status-quip">The cloud never goes down, right?</p>}
</div>
</div>
</section>
)
}
/**
* The sign-in page — sign in, plus create-account when the server says signup is open
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
* session exists, however it was obtained.
*/
function LoginPage({
account,
config,
initialTab,
navigate,
onAuthed,
}: {
account: SelfAccount | null | undefined
config: SiteConfig | undefined
initialTab: 'signup' | 'login'
navigate: Navigate
onAuthed: (a: SelfAccount) => void
}) {
// The tab IS the route (`/login` vs `/signup`) rather than local state, so the two can
// never disagree — switching tabs pushes history, and back goes back to the other one.
const tab = initialTab
useEffect(() => {
if (account) navigate('/account')
}, [account, navigate])
const authed = (a: SelfAccount) => {
onAuthed(a)
navigate('/account')
}
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
return (
<main className="shell">
<section className="card">
{siteKey && (
<div className="tabs">
<button className={tab === 'login' ? 'active' : ''} onClick={() => navigate('/login')}>
Sign in
</button>
<button
className={tab === 'signup' ? 'active' : ''}
onClick={() => navigate('/signup')}
>
Create account
</button>
</div>
)}
{siteKey && tab === 'signup' ? (
<>
<h2>Create account</h2>
<p className="muted">
A username is assigned for you you&apos;ll see it on your account page. Choose a
password, and the two together sign you in here and in the game.
</p>
<SignupForm siteKey={siteKey} onAuthed={authed} />
</>
) : (
<>
<h2>Sign in</h2>
<p className="muted">
Use your username and password. Launching the game also creates an account, linked to
your Steam ID set a password on it and it signs in here too.
</p>
<LoginForm onAuthed={authed} />
{/* The tabs above already offer this; the line under the button is where
someone who just found out they have no account is actually looking.
Gated on the same key, so it can't point at a door that isn't there. */}
{siteKey && (
<p className="muted swap">
Don&apos;t have an account?{' '}
<Link to="/signup" navigate={navigate}>
Create one
</Link>
</p>
)}
</>
)}
</section>
</main>
)
}
/** The signed-in account page. Redirects to sign-in when there's no session. */
function AccountPage({
account,
navigate,
onChange,
}: {
account: SelfAccount | null | undefined
navigate: Navigate
onChange: (a: SelfAccount) => void
}) {
useEffect(() => {
if (account === null) navigate('/login')
}, [account, navigate])
if (!account) {
return (
<main className="shell">
<p className="muted">{account === undefined ? 'Loading…' : 'Redirecting…'}</p>
</main>
)
}
return (
<main className="shell wide">
<h1>My account</h1>
<Dashboard account={account} navigate={navigate} onChange={onChange} />
</main>
)
}
/**
* One room's own page — what it is, how it's set up, and the subrooms inside it.
*
* The room is found in the caller's OWN list rather than read from the public
* `GET /rooms?id=`, which is unfiltered by design (the game looks any room up that way).
* Going through `ownedby/me` is what makes this the owner's page: a room that isn't
* yours simply isn't in the list, so there's no second ownership rule here to drift out
* of step with the one the mutating endpoints enforce.
*/
function RoomPage({
account,
roomId,
navigate,
}: {
account: SelfAccount | null | undefined
roomId: number
navigate: Navigate
}) {
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
const [error, setError] = useState('')
const accountId = account?.accountId
useEffect(() => {
if (account === null) navigate('/login')
}, [account, navigate])
useEffect(() => {
// Waits for the session: the list is auth-gated, and `account === undefined` only
// means the stored token hasn't been checked yet.
if (accountId === undefined) return
void fetchMyRooms()
.then(setRooms)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [accountId])
if (!account) {
return (
<main className="shell">
<p className="muted">{account === undefined ? 'Loading…' : 'Redirecting…'}</p>
</main>
)
}
const room = rooms?.find((r) => r.RoomId === roomId)
return (
<main className="shell wide">
<p className="backlink">
<Link to="/account" navigate={navigate}>
My rooms
</Link>
</p>
{error ? (
<p className="error">{error}</p>
) : rooms === null ? (
<p className="muted">Loading</p>
) : room === undefined ? (
// Covers both "no such room" and "someone else's" — deliberately the same
// sentence, since telling a stranger which of the two it is answers a question
// they have no business asking.
<p className="muted">That isn&apos;t one of your rooms.</p>
) : (
<RoomDetail
room={room}
imgHost={where().img}
cdnHost={where().cdn}
// A save answers with the whole updated room, so swapping it into the list
// is enough — no re-fetch, and the other rooms keep their place.
onRoomChange={(updated) =>
setRooms((current) =>
(current ?? []).map((r) => (r.RoomId === updated.RoomId ? updated : r))
)
}
/>
)}
</main>
)
}
/** The platforms a room says it supports, named the way the game names them. */
function platformList(room: OwnedRoom): string[] {
const on: string[] = []
if (room.SupportsScreens) on.push('Screens')
if (room.SupportsWalkVR) on.push('VR (walk)')
if (room.SupportsTeleportVR) on.push('VR (teleport)')
if (room.SupportsQuest2) on.push('Quest 2')
if (room.SupportsMobile) on.push('Mobile')
if (room.SupportsJuniors) on.push('Juniors')
return on
}
/**
* A room's settings and its subrooms. Its own fields are read-only — rooms are edited in
* game — with one exception: a subroom's scene data can be replaced from here, which is
* the one thing the game gives an owner no way to do (it can only save what it just
* built, never restore a file they kept).
*/
function RoomDetail({
room,
imgHost,
cdnHost,
onRoomChange,
}: {
room: OwnedRoom
imgHost: string
cdnHost: string
onRoomChange: (room: OwnedRoom) => void
}) {
const created = new Date(room.CreatedAt)
const platforms = platformList(room)
const subRooms = room.SubRooms ?? []
return (
<>
<section className="card room-hero">
{/* 512 rather than the list's 256: this one is displayed large. Both are sizes
the img worker allows, so each is a cached variant. */}
<img className="room-hero-img" src={`${imgHost}/${room.ImageName}?width=512`} alt="" />
<div className="room-hero-body">
<div className="room-head">
<h1 className="room-hero-name">^{room.Name}</h1>
<VisibilityBadge accessibility={room.Accessibility} />
</div>
{room.Description ? (
<p className="muted room-hero-desc">{room.Description}</p>
) : (
<p className="muted room-hero-desc">No description set.</p>
)}
<p className="room-stats">
{room.Stats.VisitCount.toLocaleString()} visit
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
favourite
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
</p>
</div>
</section>
<section className="card">
<h2>Settings</h2>
<dl className="facts">
<dt>Room id</dt>
<dd>{room.RoomId}</dd>
<dt>Visibility</dt>
<dd>{accessibilityLabel(room.Accessibility)}</dd>
<dt>Max players</dt>
<dd>{room.MaxPlayers}</dd>
<dt>Cloning</dt>
<dd>
{room.CloningAllowed ? 'Anyone may clone this room' : 'Nobody may clone this room'}
</dd>
<dt>Plays on</dt>
<dd>
{platforms.length > 0 ? platforms.join(', ') : 'Nothing — no platform is enabled'}
</dd>
<dt>Tags</dt>
<dd>{room.Tags?.length ? room.Tags.map((t) => t.Tag).join(', ') : 'None'}</dd>
<dt>Created</dt>
<dd>{Number.isNaN(created.getTime()) ? room.CreatedAt : created.toLocaleDateString()}</dd>
</dl>
</section>
<section className="card">
<h2>Subrooms</h2>
<p className="muted">
The places inside the room players actually load into. Each keeps its own accessibility
and its own saves, so a public room can still hold a subroom nobody else can reach.
</p>
{subRooms.length === 0 ? (
<p className="muted">This room has no subrooms.</p>
) : (
<ul className="subrooms">
{subRooms.map((sub) => (
<SubRoomRow
key={sub.SubRoomId}
sub={sub}
roomId={room.RoomId}
roomName={room.Name}
cdnHost={cdnHost}
onRoomChange={onRoomChange}
/>
))}
</ul>
)}
</section>
</>
)
}
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
function SubRoomRow({
sub,
roomId,
roomName,
cdnHost,
onRoomChange,
}: {
sub: SubRoom
roomId: number
roomName: string
cdnHost: string
onRoomChange: (room: OwnedRoom) => void
}) {
const save = sub.CurrentSave ?? null
const saved = save ? new Date(save.CreatedAt) : null
// Cleared when that save is published (see publishSubRoomSave), so a value here always
// means work the owner saved but players still can't see.
const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined
const name = sub.Name || `Subroom ${sub.SubRoomId}`
return (
<li className="subroom">
<div className="room-head">
<span className="subroom-name">{name}</span>
<VisibilityBadge accessibility={sub.Accessibility} />
{sub.IsSandbox && <span className="badge">Sandbox</span>}
</div>
<p className="subroom-meta">
#{sub.SubRoomId} · up to {sub.MaxPlayers} players
</p>
<p className="subroom-save">
{save === null ? (
// A subroom with no published save loads an empty scene without erroring, which
// from the inside looks exactly like a broken room. Say so plainly.
<span className="warn">Never published players load an empty scene.</span>
) : (
<>
Published save #{save.SubRoomDataSaveId}
{saved && !Number.isNaN(saved.getTime()) && `, saved ${saved.toLocaleString()}`}
{save.Description && ` — “${save.Description}`}
</>
)}
{staged && (
<span className="warn"> · a newer save is staged, waiting to be published.</span>
)}
</p>
{/* The published save's blob first: that's the copy of the room worth keeping,
and the one the client resolves ahead of the subroom's own key. */}
{save?.DataBlob && (
<BlobDownload
label="Save DataBlob"
blobKey={save.DataBlob}
filename={safeFilename(roomName, name, `save-${save.SubRoomDataSaveId}`)}
cdnHost={cdnHost}
/>
)}
{sub.DataBlob && (
<BlobDownload
label="Subroom DataBlob"
blobKey={sub.DataBlob}
filename={safeFilename(roomName, name, 'datablob')}
cdnHost={cdnHost}
/>
)}
<BlobUpload roomId={roomId} subRoomId={sub.SubRoomId} onRoomChange={onRoomChange} />
</li>
)
}
/**
* Replace one subroom's scene data with a file from disk.
*
* The two steps are the game's own: the bytes go to `storage` under the RoomSave type,
* and the key it hands back is posted to the subroom's `…/data` route as
* `SubRoomData.Filename`. So this is a room save like any other — it lands in the
* subroom's history beside the ones the game wrote, and both endpoints are already gated
* on the room's creator (or a co-owner), which is why there is no ownership check here:
* the page only lists rooms that came back from `ownedby/me` in the first place.
*
* Publishing is offered rather than assumed. A save normally only STAGES — players keep
* loading the last published version until the owner publishes — and quietly making an
* uploaded file live would be a bigger step than the game's own save takes. Left on by
* default all the same: someone uploading a blob here is restoring a room, and a restore
* nobody can see isn't one.
*/
function BlobUpload({
roomId,
subRoomId,
onRoomChange,
}: {
roomId: number
subRoomId: number
onRoomChange: (room: OwnedRoom) => void
}) {
const [file, setFile] = useState<File | null>(null)
const [description, setDescription] = useState('')
const [publish, setPublish] = useState(true)
// The file input is uncontrolled — React can't set its value — so clearing the picked
// file after a save takes a handle on the element itself.
const input = useRef<HTMLInputElement>(null)
const { pending, error, done, run } = useAction()
return (
<form
className="blob-upload"
onSubmit={(e) => {
e.preventDefault()
if (!file) return
void run(async () => {
const [filename, hash] = await Promise.all([uploadRoomBlob(file), blobHash(file)])
onRoomChange(
await saveSubRoomBlob(roomId, subRoomId, {
filename,
hash,
description: description.trim(),
autoPublish: publish,
})
)
setFile(null)
setDescription('')
if (input.current) input.current.value = ''
return publish
? 'Uploaded and published — players load this scene now.'
: 'Uploaded and staged. Publish it in game to make it live.'
})
}}
>
{/* Said out loud, on the control itself: this is the newest thing on the site and
the only one that overwrites what players load. Someone about to hand us a file
they can't get back should read that before the file picker, not after. */}
<p className="blob-upload-head">
<span className="blob-upload-title">Replace scene data</span>
<span className="badge beta">Beta</span>
</p>
<p className="muted blob-upload-caveat">
New and lightly tested. Nothing here checks the file the server stores whatever it
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so
scene data from a room built on anything newer may not load at all. Download the save
above and keep it before replacing it.
</p>
<label className="blob-upload-file">
Scene data file
<input
ref={input}
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</label>
<label className="blob-upload-note">
Save comment<span className="optional">optional</span>
<input
type="text"
value={description}
placeholder="Uploaded from the website"
maxLength={200}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className="check">
<input type="checkbox" checked={publish} onChange={(e) => setPublish(e.target.checked)} />
Publish it straight away
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending || file === null}>
{pending ? 'Uploading…' : 'Upload scene data'}
</button>
</form>
)
}
/**
* A download filename built from player-supplied names, with everything that isn't a
* word character, dot or dash flattened to a dash — a subroom can be called anything,
* and that string is about to become a path on someone's disk.
*/
const safeFilename = (...parts: string[]): string =>
`${parts.join('-').replace(/[^\w.-]+/g, '-')}.bin`
/**
* One scene-data blob: the key, and a link that downloads it from `cdn`.
*
* `href` is the real CDN URL, so open-in-new-tab and right-click → Save As work like any
* other link. The click is intercepted only to give the file a NAME: blobs are stored
* under a date-foldered UUID, so three downloads otherwise land as three
* indistinguishable extensionless files. The `download` attribute can't do that on its
* own — browsers ignore it cross-origin, and `cdn` is always a different origin from the
* website — hence fetching the bytes and saving them through an object URL.
*/
function BlobDownload({
label,
blobKey,
filename,
cdnHost,
}: {
label: string
blobKey: string
filename: string
cdnHost: string
}) {
// Room build data is served under `room/` — the same prefix the storage worker
// uploads it to, and the one the game downloads it from.
const url = `${cdnHost}/room/${blobKey}`
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const download = async () => {
setPending(true)
setError('')
try {
const res = await fetch(url)
// The blob key is stored on the subroom, so a miss here means the object is gone
// from the bucket — worth saying, rather than saving a file of the 404 body.
if (!res.ok) throw new Error(`the CDN answered ${res.status}`)
const href = URL.createObjectURL(await res.blob())
const link = document.createElement('a')
link.href = href
link.download = filename
link.click()
// The click is dispatched synchronously but the save reads the URL after this
// frame, so the revoke waits a tick rather than pulling it out from under.
setTimeout(() => URL.revokeObjectURL(href), 0)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setPending(false)
}
}
return (
<div className="blob">
<span className="blob-label">{label}</span>
<a
className="blob-key"
href={url}
download={filename}
onClick={(e) => {
e.preventDefault()
void download()
}}
>
{blobKey}
</a>
{/* Only rendered when it has something to say — an empty span would still take a
gap from the flex row, leaving the key trailed by a stray space. */}
{pending ? (
<span className="blob-note">Downloading</span>
) : error ? (
<span className="blob-note error">Couldnt download {error}.</span>
) : null}
</div>
)
}
/** How a room or subroom's `Accessibility` reads on screen. */
const accessibilityLabel = (accessibility: number): string =>
// Unknown ordinals shouldn't happen, but this label is the only thing telling an owner
// whether a room is visible — so show the raw value rather than nothing at all.
ACCESSIBILITY_LABEL[accessibility] ?? `Accessibility ${accessibility}`
/**
* The visibility pill. Public gets the same green "healthy" reading as the server
* status; every other value stays neutral, since Private is a choice, not a fault.
*/
function VisibilityBadge({ accessibility }: { accessibility: number }) {
return (
<span className={`badge ${accessibility === Accessibility.Public ? 'live' : ''}`}>
{accessibilityLabel(accessibility)}
</span>
)
}
/** Small hook wrapping a submit handler with pending/error/success state. */
function useAction() {
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const [done, setDone] = useState('')
const run = useCallback(async (fn: () => Promise<string>) => {
setPending(true)
setError('')
setDone('')
try {
setDone(await fn())
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setPending(false)
}
}, [])
return { pending, error, done, run }
}
/**
* Turnstile's browser API, as much of it as the signup widget uses. Loaded from
* Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in
* node_modules and has no types of its own.
*/
interface TurnstileApi {
render: (
el: HTMLElement,
opts: {
sitekey: string
action?: string
callback?: (token: string) => void
'expired-callback'?: () => void
}
) => string | undefined
reset: (widgetId?: string) => void
remove: (widgetId?: string) => void
}
declare global {
interface Window {
turnstile?: TurnstileApi
}
}
/**
* Load Turnstile's script, once per page, resolving when `window.turnstile` is ready.
* `render=explicit` stops it scanning the document for widgets: this is a SPA, so the
* container mounts and unmounts with the form and we render into it ourselves.
*
* The promise is cached at module scope, so switching tabs back and forth reuses the
* loaded script instead of appending another tag. A rejection is cached too — the retry
* is a page reload, which is what the error message asks for.
*/
let turnstileScript: Promise<void> | null = null
function loadTurnstile(): Promise<void> {
turnstileScript ??= new Promise<void>((resolve, reject) => {
const el = document.createElement('script')
el.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
el.async = true
el.defer = true
el.onload = () => resolve()
el.onerror = () => reject(new Error('load failed'))
document.head.appendChild(el)
})
return turnstileScript
}
/**
* Mount a Turnstile widget and hand back the token it produces. No token means no
* submit: the BFF refuses a signup without one, so the form gates its button on it
* rather than letting the request fail.
*
* `reset` re-arms the widget for another attempt — a token is single-use, so a rejected
* signup can't be retried with the same one.
*/
function useTurnstile(siteKey: string) {
const container = useRef<HTMLDivElement | null>(null)
const widgetId = useRef<string | undefined>(undefined)
const [token, setToken] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let live = true
loadTurnstile()
.then(() => {
// StrictMode mounts twice, and the cleanup below removes the first widget; bail
// if this effect is the stale one so we don't render into a detached container.
if (!live || !container.current || !window.turnstile) return
widgetId.current = window.turnstile.render(container.current, {
sitekey: siteKey,
// Marker Cloudflare uses to segment Turnstile integrations; carries no user data.
action: 'turnstile-spin-v1',
callback: (t) => setToken(t),
// Tokens expire after a few minutes; drop ours so the button locks again and
// Turnstile can hand us a fresh one.
'expired-callback': () => setToken(''),
})
})
.catch(() => {
if (live) setError("Couldn't load the bot check — reload the page to try again.")
})
return () => {
live = false
if (widgetId.current) window.turnstile?.remove(widgetId.current)
widgetId.current = undefined
}
}, [siteKey])
const reset = useCallback(() => {
setToken('')
if (widgetId.current) window.turnstile?.reset(widgetId.current)
}, [])
return { container, token, error, reset }
}
/**
* Create an account from the website: a password, plus a Turnstile token proving a human
* filled the form. The username comes back auto-assigned from `auth` (players don't pick
* one), and the session is live on success — so this lands on the account page, where the
* username is shown.
*/
function SignupForm({
siteKey,
onAuthed,
}: {
siteKey: string
onAuthed: (a: SelfAccount) => void
}) {
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const { container, token: widgetToken, error: widgetError, reset } = useTurnstile(siteKey)
const { pending, error, run } = useAction()
return (
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const wanted = email.trim()
try {
await signUp(password, widgetToken)
} catch (err) {
// The widget token is spent either way, so re-arm before they retry. Only
// a failed signup gets here — past this point the account exists, and a
// retry would spend another slot against auth's per-IP cap.
reset()
throw err
}
// Saved with the new session's own token: `create_account` takes no email,
// `accounts` owns the field. Deliberately not fatal — the account exists and
// the session is live, and the same field is one call away on the account
// page.
if (wanted !== '') await saveEmail(wanted).catch(() => {})
// The session is already stored, so a failure here isn't one they can act on
// by retrying: a reload finds them signed in.
const me = await fetchMe().catch(() => {
throw new Error(
'Your account was created, but loading it failed. Reload the page — you are already signed in.'
)
})
onAuthed(me)
return ''
})
}}
>
<label>
Password
<input
type="password"
value={password}
autoComplete="new-password"
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{/* Optional, and the button doesn't wait on it — but it's the only contact detail
an account has, so the hint says plainly what it's for rather than leaving it
to be guessed. `type="email"` gets the right keyboard on mobile and a free
format check; the worker re-checks it before the account is created. */}
<label>
Email <span className="optional">optional</span>
<input
type="email"
value={email}
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
/>
<span className="hint">
How you get back in if you forget your password there&apos;s no other way to reach you.
You can add it later on your account page.
</span>
</label>
<div className="turnstile" ref={container} />
{widgetError && <p className="error">{widgetError}</p>}
{error && <p className="error">{error}</p>}
<button type="submit" disabled={pending || widgetToken === ''}>
{pending ? 'Creating…' : 'Create account'}
</button>
</form>
)
}
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const { pending, error, run } = useAction()
return (
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await signIn(username, password)
onAuthed(await fetchMe())
return ''
})
}}
>
<label>
Username
<input
type="text"
value={username}
autoComplete="username"
onChange={(e) => setUsername(e.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Signing in…' : 'Sign in'}
</button>
</form>
)
}
function Dashboard({
account,
navigate,
onChange,
}: {
account: SelfAccount
navigate: Navigate
onChange: (a: SelfAccount) => void
}) {
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
// sections are appended when the session carries an admin role.
const sections = [
// First, so a player who just signed in lands on what they made rather than on a
// settings form they opened the page to avoid.
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> },
{
id: 'username',
label: 'Username',
render: () => <UsernameForm account={account} onChange={onChange} />,
},
{
id: 'email',
label: 'Email',
render: () => <EmailForm account={account} onChange={onChange} />,
},
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
...(isAdmin()
? [
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
]
: []),
]
const [active, setActive] = useState(sections[0].id)
const current = sections.find((s) => s.id === active) ?? sections[0]
return (
<>
<section className="card identity">
<div className="muted">Signed in as</div>
<div className="big">{account.displayName || account.username}</div>
<div className="handle">
@{account.username} · #{account.accountId} · {account.email ?? 'no email set'}
</div>
</section>
<div className="workspace">
<nav className="vtabs">
{sections.map((s) => (
<button
key={s.id}
className={s.id === active ? 'active' : ''}
onClick={() => setActive(s.id)}
>
{s.label}
</button>
))}
</nav>
<div className="panel">{current.render()}</div>
</div>
</>
)
}
/**
* The rooms the signed-in player owns.
*
* Read-only on purpose: rooms are made and edited in game, and there is nothing here a
* player could change that the game doesn't already own. What the web is better at is
* the overview — everything you've made in one place, including the rooms you never
* published, which are invisible everywhere else.
*/
function MyRooms({ navigate }: { navigate: Navigate }) {
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
const [error, setError] = useState('')
useEffect(() => {
void fetchMyRooms()
.then(setRooms)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [])
return (
<section className="card">
<h2>My rooms</h2>
<p className="muted">
Every room you&apos;ve made, newest first unpublished ones included. Your dorm isn&apos;t
here: it was made for you rather than by you.
</p>
{error ? (
<p className="error">{error}</p>
) : rooms === null ? (
<p className="muted">Loading</p>
) : rooms.length === 0 ? (
<p className="muted">
You haven&apos;t made a room yet. Rooms are created in game clone one you like, or start
from a blank one in the Rec Center.
</p>
) : (
// `where()` THROWS when the config never landed, and a throw in render takes the
// page down (see useSlideshow). It can't here: this branch is only reached once
// the fetch above resolved, and that fetch went through `where()` itself.
<ul className="rooms">
{rooms.map((room) => (
<RoomCard key={room.RoomId} room={room} imgHost={where().img} navigate={navigate} />
))}
</ul>
)}
</section>
)
}
/**
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
* doing. The whole row links to the room's own page.
*
* The thumbnail is asked for at 256px wide — one of the img worker's four allowed sizes,
* so it's a cached variant rather than the full-size upload. A room with no image of its
* own still answers 200 there (the worker serves its fallback), so there's no broken
* frame to handle.
*/
function RoomCard({
room,
imgHost,
navigate,
}: {
room: OwnedRoom
imgHost: string
navigate: Navigate
}) {
const created = new Date(room.CreatedAt)
return (
<li className="room">
{/* A real `<a href>` (see Link), not a click handler on the row: it has to be
reachable by keyboard, and openable in a new tab like any other link. */}
<Link to={`/rooms/${room.RoomId}`} navigate={navigate} className="room-link">
<img
className="room-thumb"
src={`${imgHost}/${room.ImageName}?width=256`}
alt=""
loading="lazy"
/>
<div className="room-body">
<div className="room-head">
{/* The caret is how the game writes a room name, so it reads as the thing you
type to get there rather than as a title someone wrote. */}
<span className="room-name">^{room.Name}</span>
<VisibilityBadge accessibility={room.Accessibility} />
</div>
{room.Description && <p className="room-desc">{room.Description}</p>}
<p className="room-stats">
{room.Stats.VisitCount.toLocaleString()} visit
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
favourite
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
{!Number.isNaN(created.getTime()) && ` · made ${created.toLocaleDateString()}`}
</p>
</div>
</Link>
</li>
)
}
/** Admin-only: send a coach/system message to every online player. */
function CoachMessageForm() {
const [message, setMessage] = useState('')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Broadcast message</h2>
<p className="muted">
Send a message from the Coach to every connected player. Players who aren&apos;t online
won&apos;t receive it.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { sent } = await coachMessageAll(message.trim())
setMessage('')
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
})
}}
>
<label>
Message
<textarea
value={message}
rows={3}
onChange={(e) => setMessage(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send to all online'}
</button>
</form>
</section>
)
}
/** Admin-only: broadcast a server-maintenance countdown to every connected client. */
function MaintenanceForm() {
const [minutes, setMinutes] = useState('5')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Server maintenance</h2>
<p className="muted">
Broadcast a maintenance countdown to every connected client. Enter how many minutes until
maintenance starts (0 = now).
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
// Coerced the way the worker used to: a blank or negative box means "now".
const asked = Number(minutes)
const startsIn = Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : 0
const { delivered: connections } = await broadcastMaintenance(startsIn)
return `Notified ${connections ?? 0} connected client${connections === 1 ? '' : 's'}.`
})
}}
>
<label>
Starts in (minutes)
<input
type="number"
min="0"
step="1"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Broadcasting…' : 'Broadcast maintenance'}
</button>
</form>
</section>
)
}
/**
* Change the account's username — the name used to sign in, here and in the game.
*
* Changes are rationed (an account starts with one), so the count is stated up front and
* the form locks itself once none are left rather than letting someone spend the attempt
* finding out. The server is still the one that decides: an unknown count leaves the form
* open, and a name taken since the page loaded is refused upstream.
*
* The response is the caller's whole self account, re-read after the write, so the
* remaining count on screen is the stored one and not a guess.
*/
function UsernameForm({
account,
onChange,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
}) {
const [username, setUsername] = useState(account.username)
const { pending, error, done, run } = useAction()
const remaining = account.availableUsernameChanges
const spent = remaining !== undefined && remaining <= 0
// Retyping the current name would be refused upstream anyway ("already taken" is
// waived for your own name, but it would still spend a change).
const unchanged = username.trim() === account.username
return (
<section className="card">
<h2>Username</h2>
<p className="muted">
What you sign in with, here and in the game and what other players see you by.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const updated = await changeUsername(username.trim())
onChange(updated)
setUsername(updated.username)
return `You are now @${updated.username}.`
})
}}
>
<label>
Username
<input
type="text"
value={username}
autoComplete="username"
disabled={spent}
onChange={(e) => setUsername(e.target.value)}
required
/>
<span className="hint">
{remaining === undefined
? 'Changing your username uses up one of a limited number of changes.'
: spent
? 'You have no username changes remaining, so this can no longer be changed.'
: `You have ${remaining} username change${remaining === 1 ? '' : 's'} remaining — this one is permanent once used.`}
</span>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending || spent || unchanged}>
{pending ? 'Changing…' : 'Change username'}
</button>
</form>
</section>
)
}
function EmailForm({
account,
onChange,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
}) {
const [email, setEmail] = useState(account.email ?? '')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Email</h2>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await saveEmail(email.trim())
onChange({ ...account, email })
return 'Email saved.'
})
}}
>
<label>
Email address
<input
type="email"
value={email}
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : 'Save email'}
</button>
</form>
</section>
)
}
function PasswordForm() {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Password</h2>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await changePassword(oldPassword, newPassword)
setOldPassword('')
setNewPassword('')
return 'Password changed.'
})
}}
>
<label>
Current password
<input
type="password"
value={oldPassword}
autoComplete="current-password"
onChange={(e) => setOldPassword(e.target.value)}
required
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
autoComplete="new-password"
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Updating…' : 'Change password'}
</button>
</form>
</section>
)
}