diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index b2ec284..a334332 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -191,6 +191,13 @@ async function fetchMyRooms(): Promise { */ 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 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. */ async function uploadRoomBlob(file: File): Promise { + return uploadToStorage(file, FILE_TYPE_ROOM_SAVE) +} + +/** + * Post a file to `storage` under one `UploadFileType` and return the generated + * `/` 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 { const form = new FormData() - form.set('FileType', FILE_TYPE_ROOM_SAVE) + form.set('FileType', fileType) form.set('File', file) const { filename } = await call<{ filename?: string }>(`${where().storage}/upload`, { method: 'POST', @@ -224,6 +241,26 @@ async function uploadRoomBlob(file: File): Promise { 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 { + 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 * `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 - * 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). + * game — with two exceptions, both things the game gives an owner no way to do: a + * subroom's scene data can be replaced from here (the game can only save what it just + * 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({ room, @@ -1359,9 +1397,15 @@ function RoomDetail({ 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. */} - +
+ {/* 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. */} + + onRoomChange({ ...room, ImageName: imageName })} + /> +

^{room.Name}

@@ -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(null) + const input = useRef(null) + const { pending, error, done, run } = useAction() + + return ( +
{ + 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.' + }) + }} + > +

+ Replace image +

+

+ A JPEG or PNG, landscape (3:2) like a photo taken in game. It goes live as soon as it + uploads. +

+ + {error &&

{error}

} + {done &&

{done}

} + +
+ ) +} + /** * 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, diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index f6fec40..7c63392 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -769,6 +769,20 @@ h2 { 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 { font-family: var(--display); font-weight: 700;