diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 5581fca..7f22d4e 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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 = { [Accessibility.Private]: 'Private', @@ -414,6 +453,16 @@ function Link({ ) } +/** + * The room id in `/rooms/`, 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(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(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' ? ( + ) : roomId !== null ? ( + ) : ( )} @@ -891,11 +943,224 @@ function AccountPage({ return (

My account

- +
) } +/** + * 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(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 ( +
+

{account === undefined ? 'Loading…' : 'Redirecting…'}

+
+ ) + } + + const room = rooms?.find((r) => r.RoomId === roomId) + + return ( +
+

+ + ← My rooms + +

+ {error ? ( +

{error}

+ ) : rooms === null ? ( +

Loading…

+ ) : 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. +

That isn't one of your rooms.

+ ) : ( + + )} +
+ ) +} + +/** 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 ( + <> +
+ {/* 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. */} + +
+
+

^{room.Name}

+ +
+ {room.Description ? ( +

{room.Description}

+ ) : ( +

No description set.

+ )} +

+ {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'} +

+
+
+ +
+

Settings

+
+
Room id
+
{room.RoomId}
+
Visibility
+
{accessibilityLabel(room.Accessibility)}
+
Max players
+
{room.MaxPlayers}
+
Cloning
+
+ {room.CloningAllowed ? 'Anyone may clone this room' : 'Nobody may clone this room'} +
+
Plays on
+
+ {platforms.length > 0 ? platforms.join(', ') : 'Nothing — no platform is enabled'} +
+
Tags
+
{room.Tags?.length ? room.Tags.map((t) => t.Tag).join(', ') : 'None'}
+
Created
+
{Number.isNaN(created.getTime()) ? room.CreatedAt : created.toLocaleDateString()}
+
+
+ +
+

Subrooms

+

+ 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. +

+ {subRooms.length === 0 ? ( +

This room has no subrooms.

+ ) : ( +
    + {subRooms.map((sub) => ( + + ))} +
+ )} +
+ + ) +} + +/** 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 ( +
  • +
    + {sub.Name || `Subroom ${sub.SubRoomId}`} + + {sub.IsSandbox && Sandbox} +
    +

    + #{sub.SubRoomId} · up to {sub.MaxPlayers} players + {sub.UnitySceneId && ` · scene ${sub.UnitySceneId}`} +

    +

    + {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. + Never published — players load an empty scene. + ) : ( + <> + Published save #{save.SubRoomDataSaveId} + {saved && !Number.isNaN(saved.getTime()) && `, saved ${saved.toLocaleString()}`} + {save.Description && ` — “${save.Description}”`} + + )} + {staged && ( + · a newer save is staged, waiting to be published. + )} +

    +
  • + ) +} + +/** 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 ( + + {accessibilityLabel(accessibility)} + + ) +} + /** 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: () => }, + { id: 'rooms', label: 'My rooms', render: () => }, { 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(null) const [error, setError] = useState('') @@ -1251,7 +1518,7 @@ function MyRooms() { // the fetch above resolved, and that fetch went through `where()` itself.
      {rooms.map((room) => ( - + ))}
    )} @@ -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 (
  • - -
    -
    - {/* 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. */} - ^{room.Name} - - {visibility} - + {/* A real `` (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. */} + + +
    +
    + {/* 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. */} + ^{room.Name} + +
    + {room.Description &&

    {room.Description}

    } +

    + {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()}`} +

    - {room.Description &&

    {room.Description}

    } -

    - {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()}`} -

    -
    +
  • ) } diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index d506896..0d357ad 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -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) {