[www] subroom listing

This commit is contained in:
Devin Zuczek
2026-08-10 11:14:24 -04:00
parent 5c5988c730
commit 6c7a634cb6
2 changed files with 460 additions and 44 deletions
+313 -40
View File
@@ -60,9 +60,36 @@ interface SelfAccount {
}
/**
* One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what this page draws. The
* worker serves the stored room blob verbatim — dozens of fields the game needs and this
* list doesn't — so only the handful read here is declared.
* 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
/** The Unity scene the client loads under the saved objects. */
UnitySceneId: string
/** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */
Accessibility: number
IsSandbox: boolean
MaxPlayers: number
/**
* 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 } | 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
@@ -73,6 +100,18 @@ interface OwnedRoom {
/** 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
@@ -83,9 +122,9 @@ interface OwnedRoom {
}
/**
* What a room's `Accessibility` ordinal is called on screen. 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 RoomCard.
* 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',
@@ -414,6 +453,16 @@ function Link({
)
}
/**
* 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)
@@ -421,6 +470,7 @@ export function App() {
// 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
@@ -469,6 +519,8 @@ export function App() {
/>
) : 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} />
)}
@@ -891,11 +943,224 @@ function AccountPage({
return (
<main className="shell wide">
<h1>My account</h1>
<Dashboard account={account} onChange={onChange} />
<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} />
)}
</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. Read-only: rooms are edited in game. */
function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
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} />
))}
</ul>
)}
</section>
</>
)
}
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
function SubRoomRow({ sub }: { sub: SubRoom }) {
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
return (
<li className="subroom">
<div className="room-head">
<span className="subroom-name">{sub.Name || `Subroom ${sub.SubRoomId}`}</span>
<VisibilityBadge accessibility={sub.Accessibility} />
{sub.IsSandbox && <span className="badge">Sandbox</span>}
</div>
<p className="subroom-meta">
#{sub.SubRoomId} · up to {sub.MaxPlayers} players
{sub.UnitySceneId && ` · scene ${sub.UnitySceneId}`}
</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>
</li>
)
}
/** 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)
@@ -1152,9 +1417,11 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
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
@@ -1162,7 +1429,7 @@ function Dashboard({
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 /> },
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> },
{
id: 'username',
label: 'Username',
@@ -1219,7 +1486,7 @@ function Dashboard({
* the overview — everything you've made in one place, including the rooms you never
* published, which are invisible everywhere else.
*/
function MyRooms() {
function MyRooms({ navigate }: { navigate: Navigate }) {
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
const [error, setError] = useState('')
@@ -1251,7 +1518,7 @@ function MyRooms() {
// 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} />
<RoomCard key={room.RoomId} room={room} imgHost={where().img} navigate={navigate} />
))}
</ul>
)}
@@ -1261,47 +1528,53 @@ function MyRooms() {
/**
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
* doing.
* 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 }: { room: OwnedRoom; imgHost: string }) {
// Unknown ordinals shouldn't happen, but the label is the only thing telling an owner
// whether a room is visible — so show the raw value rather than nothing at all.
const visibility =
ACCESSIBILITY_LABEL[room.Accessibility] ?? `Accessibility ${room.Accessibility}`
function RoomCard({
room,
imgHost,
navigate,
}: {
room: OwnedRoom
imgHost: string
navigate: Navigate
}) {
const created = new Date(room.CreatedAt)
return (
<li className="room">
<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>
<span className={`badge ${room.Accessibility === Accessibility.Public ? 'live' : ''}`}>
{visibility}
</span>
{/* 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>
{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>
)
}
+147 -4
View File
@@ -622,10 +622,6 @@ h2 {
}
.room {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 14px;
align-items: start;
padding-top: 12px;
border-top: 1px solid var(--line);
}
@@ -636,6 +632,29 @@ h2 {
border-top: none;
}
/* The whole row is the link to the room's page, so the target is the size of the thing
you're aiming at rather than the name alone. Padded out to the card edge and pulled
back by the same amount, so the hover fill covers the row instead of stopping short. */
.room-link {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 14px;
align-items: start;
margin: 0 -10px;
padding: 10px;
border-radius: 8px;
color: inherit;
text-decoration: none;
}
.room-link:hover {
background: var(--surface-hi);
}
.room-link:hover .room-name {
color: var(--accent);
}
/* Fixed 3:2 frame — room thumbnails are screenshots and arrive at any ratio, and a
per-room height would leave the names in a ragged column. */
.room-thumb {
@@ -707,6 +726,115 @@ h2 {
font-variant-numeric: tabular-nums;
}
/* ---- One room's page ---------------------------------------------------- */
/* The way back out, above the page's own heading. */
.backlink {
margin: 0 0 16px;
font-size: 0.9rem;
}
.backlink a {
color: var(--muted);
text-decoration: none;
}
.backlink a:hover {
color: var(--text);
}
/* Wider thumbnail than the list's, and the name beside it rather than under: this is
the page about this one room, so the photo can carry some of the weight. */
.room-hero {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.room-hero-img {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-hi);
}
.room-hero-name {
font-family: var(--display);
font-weight: 700;
font-size: 1.5rem;
letter-spacing: -0.02em;
margin: 0;
overflow-wrap: anywhere;
}
.room-hero-desc {
margin: 10px 0 0;
overflow-wrap: anywhere;
}
/* Label/value pairs. A two-column grid rather than a list of "Label: value" lines, so
the values line up and the page can be read down one column. */
.facts {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
gap: 8px 16px;
margin: 0;
font-size: 0.9rem;
}
.facts dt {
color: var(--muted);
}
.facts dd {
margin: 0;
overflow-wrap: anywhere;
}
.subrooms {
list-style: none;
margin: 18px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.subroom {
padding-top: 14px;
border-top: 1px solid var(--line);
}
.subroom:first-child {
padding-top: 0;
border-top: none;
}
.subroom-name {
font-family: var(--display);
font-weight: 700;
font-size: 0.975rem;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.subroom-meta,
.subroom-save {
margin: 6px 0 0;
font-size: 0.825rem;
color: var(--muted);
overflow-wrap: anywhere;
}
/* Something the owner probably wants to act on — an unpublished subroom, a staged save
nobody can see yet. Not an error: nothing is broken, it just isn't live. */
.warn {
color: var(--accent);
}
/* ---- Forms -------------------------------------------------------------- */
label {
@@ -882,6 +1010,21 @@ button[type='submit']:disabled {
.vtabs button {
border-color: var(--line);
}
/* The room page's hero and its fact table both stack: 260px of photo (or 150px of
label) beside a value leaves the value in a column too narrow to read. */
.room-hero {
grid-template-columns: 1fr;
}
.facts {
grid-template-columns: 1fr;
gap: 2px 0;
}
.facts dd {
margin-bottom: 10px;
}
}
@media (prefers-reduced-motion: reduce) {