mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
[www] subroom data
This commit is contained in:
+137
-9
@@ -33,6 +33,7 @@ interface Hosts {
|
|||||||
img: string
|
img: string
|
||||||
notify: string
|
notify: string
|
||||||
rooms: string
|
rooms: string
|
||||||
|
cdn: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,12 +68,12 @@ interface SelfAccount {
|
|||||||
interface SubRoom {
|
interface SubRoom {
|
||||||
SubRoomId: number
|
SubRoomId: number
|
||||||
Name: string
|
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. */
|
/** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */
|
||||||
Accessibility: number
|
Accessibility: number
|
||||||
IsSandbox: boolean
|
IsSandbox: boolean
|
||||||
MaxPlayers: number
|
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
|
* 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".
|
* published, so a non-null value means "edited since players last saw a change".
|
||||||
@@ -83,7 +84,17 @@ interface SubRoom {
|
|||||||
* one silently loads nothing, which is worth surfacing to an owner who can't tell
|
* one silently loads nothing, which is worth surfacing to an owner who can't tell
|
||||||
* that apart from a broken room.
|
* that apart from a broken room.
|
||||||
*/
|
*/
|
||||||
CurrentSave: { SubRoomDataSaveId: number; CreatedAt: string; Description: string } | null
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1010,7 +1021,7 @@ function RoomPage({
|
|||||||
// they have no business asking.
|
// they have no business asking.
|
||||||
<p className="muted">That isn't one of your rooms.</p>
|
<p className="muted">That isn't one of your rooms.</p>
|
||||||
) : (
|
) : (
|
||||||
<RoomDetail room={room} imgHost={where().img} />
|
<RoomDetail room={room} imgHost={where().img} cdnHost={where().cdn} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -1029,7 +1040,15 @@ function platformList(room: OwnedRoom): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A room's settings and its subrooms. Read-only: rooms are edited in game. */
|
/** A room's settings and its subrooms. Read-only: rooms are edited in game. */
|
||||||
function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
|
function RoomDetail({
|
||||||
|
room,
|
||||||
|
imgHost,
|
||||||
|
cdnHost,
|
||||||
|
}: {
|
||||||
|
room: OwnedRoom
|
||||||
|
imgHost: string
|
||||||
|
cdnHost: string
|
||||||
|
}) {
|
||||||
const created = new Date(room.CreatedAt)
|
const created = new Date(room.CreatedAt)
|
||||||
const platforms = platformList(room)
|
const platforms = platformList(room)
|
||||||
const subRooms = room.SubRooms ?? []
|
const subRooms = room.SubRooms ?? []
|
||||||
@@ -1095,7 +1114,7 @@ function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<ul className="subrooms">
|
<ul className="subrooms">
|
||||||
{subRooms.map((sub) => (
|
{subRooms.map((sub) => (
|
||||||
<SubRoomRow key={sub.SubRoomId} sub={sub} />
|
<SubRoomRow key={sub.SubRoomId} sub={sub} roomName={room.Name} cdnHost={cdnHost} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -1105,23 +1124,31 @@ function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
|
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
|
||||||
function SubRoomRow({ sub }: { sub: SubRoom }) {
|
function SubRoomRow({
|
||||||
|
sub,
|
||||||
|
roomName,
|
||||||
|
cdnHost,
|
||||||
|
}: {
|
||||||
|
sub: SubRoom
|
||||||
|
roomName: string
|
||||||
|
cdnHost: string
|
||||||
|
}) {
|
||||||
const save = sub.CurrentSave ?? null
|
const save = sub.CurrentSave ?? null
|
||||||
const saved = save ? new Date(save.CreatedAt) : null
|
const saved = save ? new Date(save.CreatedAt) : null
|
||||||
// Cleared when that save is published (see publishSubRoomSave), so a value here always
|
// Cleared when that save is published (see publishSubRoomSave), so a value here always
|
||||||
// means work the owner saved but players still can't see.
|
// means work the owner saved but players still can't see.
|
||||||
const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined
|
const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined
|
||||||
|
const name = sub.Name || `Subroom ${sub.SubRoomId}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="subroom">
|
<li className="subroom">
|
||||||
<div className="room-head">
|
<div className="room-head">
|
||||||
<span className="subroom-name">{sub.Name || `Subroom ${sub.SubRoomId}`}</span>
|
<span className="subroom-name">{name}</span>
|
||||||
<VisibilityBadge accessibility={sub.Accessibility} />
|
<VisibilityBadge accessibility={sub.Accessibility} />
|
||||||
{sub.IsSandbox && <span className="badge">Sandbox</span>}
|
{sub.IsSandbox && <span className="badge">Sandbox</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="subroom-meta">
|
<p className="subroom-meta">
|
||||||
#{sub.SubRoomId} · up to {sub.MaxPlayers} players
|
#{sub.SubRoomId} · up to {sub.MaxPlayers} players
|
||||||
{sub.UnitySceneId && ` · scene ${sub.UnitySceneId}`}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="subroom-save">
|
<p className="subroom-save">
|
||||||
{save === null ? (
|
{save === null ? (
|
||||||
@@ -1139,10 +1166,111 @@ function SubRoomRow({ sub }: { sub: SubRoom }) {
|
|||||||
<span className="warn"> · a newer save is staged, waiting to be published.</span>
|
<span className="warn"> · a newer save is staged, waiting to be published.</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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">Couldn’t download — {error}.</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** How a room or subroom's `Accessibility` reads on screen. */
|
/** How a room or subroom's `Accessibility` reads on screen. */
|
||||||
const accessibilityLabel = (accessibility: number): string =>
|
const accessibilityLabel = (accessibility: number): string =>
|
||||||
// Unknown ordinals shouldn't happen, but this label is the only thing telling an owner
|
// Unknown ordinals shouldn't happen, but this label is the only thing telling an owner
|
||||||
|
|||||||
@@ -835,6 +835,44 @@ h2 {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A scene-data blob and the link that downloads it. The KEY is the link, rather than a
|
||||||
|
* "Download" button beside it: the key is the thing an owner came to find (it's what
|
||||||
|
* `CurrentSave.DataBlob` holds, and what a bug report quotes), so making it the target
|
||||||
|
* keeps it readable and clickable without a second control competing for the row.
|
||||||
|
*/
|
||||||
|
.blob {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-label {
|
||||||
|
flex: none;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blob keys are `<date>/<uuid>` — long, and with only two places a line could break.
|
||||||
|
`anywhere` lets one wrap mid-key instead of pushing the card sideways. */
|
||||||
|
.blob-key {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-key:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-note {
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Forms -------------------------------------------------------------- */
|
/* ---- Forms -------------------------------------------------------------- */
|
||||||
|
|
||||||
label {
|
label {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ it('advertises signup and where the other workers live', async () => {
|
|||||||
img: 'https://img.rec.example.com',
|
img: 'https://img.rec.example.com',
|
||||||
notify: 'https://notify.rec.example.com',
|
notify: 'https://notify.rec.example.com',
|
||||||
rooms: 'https://rooms.rec.example.com',
|
rooms: 'https://rooms.rec.example.com',
|
||||||
|
cdn: 'https://cdn.rec.example.com',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}`
|
|||||||
export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
|
export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
|
||||||
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
|
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
|
||||||
export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}`
|
export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}`
|
||||||
|
export const cdnBase = (env: Env): string => `https://cdn.${env.DOMAIN}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST a form body to the `auth` worker, carrying the browser's real IP across.
|
* POST a form body to the `auth` worker, carrying the browser's real IP across.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
accountsBase,
|
accountsBase,
|
||||||
apiBase,
|
apiBase,
|
||||||
authBase,
|
authBase,
|
||||||
|
cdnBase,
|
||||||
imgBase,
|
imgBase,
|
||||||
notifyBase,
|
notifyBase,
|
||||||
postAuthForm,
|
postAuthForm,
|
||||||
@@ -69,6 +70,7 @@ const app = new Hono<App>()
|
|||||||
img: imgBase(c.env),
|
img: imgBase(c.env),
|
||||||
notify: notifyBase(c.env),
|
notify: notifyBase(c.env),
|
||||||
rooms: roomsBase(c.env),
|
rooms: roomsBase(c.env),
|
||||||
|
cdn: cdnBase(c.env),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user