[www] room image upload

This commit is contained in:
Devin Zuczek
2026-09-16 00:50:07 -04:00
parent 95d0cc2856
commit e5b0836755
2 changed files with 142 additions and 7 deletions
+128 -7
View File
@@ -191,6 +191,13 @@ async function fetchMyRooms(): Promise<OwnedRoom[]> {
*/ */
const FILE_TYPE_ROOM_SAVE = '1' const FILE_TYPE_ROOM_SAVE = '1'
/**
* The `UploadFileType` a picture is posted under. `storage` files it under `image/`,
* which is where the `img` worker resolves an extensionless key — so the name it hands
* back is exactly what a room's `ImageName` holds, and what the hero draws.
*/
const FILE_TYPE_IMAGE = '3'
/** /**
* The game build this server targets, as `YYYY-MM-DD` — read from the same `GAME_VERSION` * 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 * the auth token and presence carry rather than written out again here, so upgrading the
@@ -212,8 +219,18 @@ const CLIENT_BUILD_DATE = `${GAME_VERSION.slice(0, 4)}-${GAME_VERSION.slice(4, 6
* honest validation available is whether the game can load it afterwards. * honest validation available is whether the game can load it afterwards.
*/ */
async function uploadRoomBlob(file: File): Promise<string> { async function uploadRoomBlob(file: File): Promise<string> {
return uploadToStorage(file, FILE_TYPE_ROOM_SAVE)
}
/**
* Post a file to `storage` under one `UploadFileType` and return the generated
* `<date>/<uuid>` key. The type decides the bucket folder, and so which worker can serve
* the file back: a scene blob is only reachable through `cdn`, a picture only through
* `img`, so a caller has to name the right one for the key to mean anything later.
*/
async function uploadToStorage(file: File, fileType: string): Promise<string> {
const form = new FormData() const form = new FormData()
form.set('FileType', FILE_TYPE_ROOM_SAVE) form.set('FileType', fileType)
form.set('File', file) form.set('File', file)
const { filename } = await call<{ filename?: string }>(`${where().storage}/upload`, { const { filename } = await call<{ filename?: string }>(`${where().storage}/upload`, {
method: 'POST', method: 'POST',
@@ -224,6 +241,26 @@ async function uploadRoomBlob(file: File): Promise<string> {
return filename return filename
} }
/**
* Point a room at an already-uploaded picture — `PUT /rooms/{id}/image`, the call the
* game makes after a player picks a photo for the room.
*
* Owner-only on the server, which is one rule stricter than the scene-data save (a
* co-owner may save, but not change the image). The reply is the PascalCase
* `{ Success, Error }` envelope at HTTP 200 with a null `Value` — it does NOT carry the
* room, so the page patches `ImageName` onto the room it already has rather than
* re-fetching. That's the same thing the game does on the `RoomUpdate` this pushes.
*/
async function setRoomImage(roomId: number, imageName: string): Promise<void> {
const res = await call<{ Success?: boolean; Error?: string | null }>(
`${where().rooms}/rooms/${roomId}/image`,
{ method: 'PUT', authed: true, form: { imageName } }
)
if (res.Success !== true) {
throw new Error(res.Error || 'The rooms worker refused the image.')
}
}
/** /**
* The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's * 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 * `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a
@@ -1337,9 +1374,10 @@ function platformList(room: OwnedRoom): string[] {
/** /**
* A room's settings and its subrooms. Its own fields are read-only — rooms are edited in * 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 * game — with two exceptions, both things the game gives an owner no way to do: a
* the one thing the game gives an owner no way to do (it can only save what it just * subroom's scene data can be replaced from here (the game can only save what it just
* built, never restore a file they kept). * built, never restore a file they kept), and so can the room's image (the game can only
* set it to a photo taken inside the room).
*/ */
function RoomDetail({ function RoomDetail({
room, room,
@@ -1359,9 +1397,15 @@ function RoomDetail({
return ( return (
<> <>
<section className="card room-hero"> <section className="card room-hero">
{/* 512 rather than the list's 256: this one is displayed large. Both are sizes <div className="room-hero-media">
the img worker allows, so each is a cached variant. */} {/* 512 rather than the list's 256: this one is displayed large. Both are sizes
<img className="room-hero-img" src={`${imgHost}/${room.ImageName}?width=512`} alt="" /> the img worker allows, so each is a cached variant. */}
<img className="room-hero-img" src={`${imgHost}/${room.ImageName}?width=512`} alt="" />
<RoomImageUpload
roomId={room.RoomId}
onImageChange={(imageName) => onRoomChange({ ...room, ImageName: imageName })}
/>
</div>
<div className="room-hero-body"> <div className="room-hero-body">
<div className="room-head"> <div className="room-head">
<h1 className="room-hero-name">^{room.Name}</h1> <h1 className="room-hero-name">^{room.Name}</h1>
@@ -1606,6 +1650,83 @@ function BlobUpload({
) )
} }
/**
* Picture types the room thumbnail accepts. The `img` worker decodes whatever it serves
* with Photon to resize it, and these two are the ones the game itself produces (a photo
* is a JPEG; a PNG keeps its alpha through the resize). Checked on the type the browser
* reports rather than the extension, and the server's own check still stands behind it.
*/
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png'])
/**
* Replace a room's thumbnail with a picture from disk.
*
* Same two steps as the scene-data upload, with a different `FileType`: the bytes go to
* `storage` under the Image type, and the key it hands back is put to the room's
* `…/image` route as `imageName`. In game the only way to set this is to take a photo in
* the room, so this is how an owner gets a picture they made elsewhere onto the room.
*
* Unlike the scene-data form there is no publish step: the image is live the moment the
* route answers, for everyone browsing rooms. The hero above redraws from the new
* `ImageName` straight away — `img` caches by key and the key is new, so nothing stale
* can be served.
*/
function RoomImageUpload({
roomId,
onImageChange,
}: {
roomId: number
onImageChange: (imageName: string) => void
}) {
const [file, setFile] = useState<File | null>(null)
const input = useRef<HTMLInputElement>(null)
const { pending, error, done, run } = useAction()
return (
<form
className="blob-upload room-image-upload"
onSubmit={(e) => {
e.preventDefault()
if (!file) return
void run(async () => {
if (!IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a JPEG or PNG image.')
}
const imageName = await uploadToStorage(file, FILE_TYPE_IMAGE)
await setRoomImage(roomId, imageName)
onImageChange(imageName)
setFile(null)
if (input.current) input.current.value = ''
return 'Image replaced — it shows in game now.'
})
}}
>
<p className="blob-upload-head">
<span className="blob-upload-title">Replace image</span>
</p>
<p className="muted blob-upload-caveat">
A JPEG or PNG, landscape (3:2) like a photo taken in game. It goes live as soon as it
uploads.
</p>
<label className="blob-upload-file">
Image file
<input
ref={input}
type="file"
accept="image/jpeg,image/png"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending || file === null}>
{pending ? 'Uploading…' : 'Upload image'}
</button>
</form>
)
}
/** /**
* A download filename built from player-supplied names, with everything that isn't a * 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, * word character, dot or dash flattened to a dash — a subroom can be called anything,
+14
View File
@@ -769,6 +769,20 @@ h2 {
background: var(--surface-hi); background: var(--surface-hi);
} }
/* The picture and the form that replaces it share the narrow column, so the control sits
under the thing it changes. The form drops the blob-upload's top margin for the
picture's own gap and keeps its border, which is what marks it as a control on a page
whose other room fields are read-only. */
.room-hero-media {
display: flex;
flex-direction: column;
gap: 10px;
}
.room-image-upload {
margin-top: 0;
}
.room-hero-name { .room-hero-name {
font-family: var(--display); font-family: var(--display);
font-weight: 700; font-weight: 700;