mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[www] room image upload
This commit is contained in:
+128
-7
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user