[www] pfp upload

This commit is contained in:
Devin Zuczek
2026-09-25 11:02:42 -04:00
parent 5178f18c79
commit 9c4c1bdd3a
2 changed files with 236 additions and 52 deletions
+171 -40
View File
@@ -61,6 +61,8 @@ interface SelfAccount {
username: string
displayName: string
email: string | null
/** A key on the `img` worker — `DefaultProfileImage.jpg` until the player sets one. */
profileImage: string
/**
* Username changes left on the account — each change spends one, and an account
* starts with one. Absent on an older self DTO, which reads as "unknown": the form
@@ -295,6 +297,50 @@ async function setRoomImage(roomId: number, imageName: string): Promise<void> {
}
}
/**
* The `SavedImageType` a profile picture is uploaded under. `api` files the object under
* `profile/` and, seeing this type, writes it onto the account row as well — so the
* upload alone would change the avatar. The game still follows it with the `accounts`
* PUT (see `setProfileImage`), and so does this page: that call is the one that pushes
* the `AccountUpdate` frame, without which a running client keeps drawing the old face.
*/
const SAVED_IMAGE_TYPE_PROFILE = 4
/**
* Upload a profile picture the way the game does — `POST /api/images/v4/uploadsaved` on
* `api`, NOT `storage`'s `/upload` that the room image uses. The two land in different
* buckets: a room image is a plain object `img` serves by key, while a saved image also
* gets a row in the `image` table (typed as a profile thumbnail, so it stays out of the
* photo feed, which lists share-camera shots only). Returns the key `img` serves it by.
*/
async function uploadProfileImage(file: File): Promise<string> {
const form = new FormData()
form.set('imgMeta', JSON.stringify({ savedImageType: SAVED_IMAGE_TYPE_PROFILE }))
form.set('image', file)
const { ImageName } = await call<{ ImageName?: string }>(
`${where().api}/api/images/v4/uploadsaved`,
{ method: 'POST', multipart: form, authed: true }
)
if (!ImageName) throw new Error('The api worker accepted the picture but returned no name.')
return ImageName
}
/**
* Point the account at an already-uploaded picture — `PUT /account/me/profileimage` on
* `accounts`, the call the game makes after the upload. Answers the lowercase
* `{ success }` envelope, not the room image's PascalCase one, and carries no account:
* the page patches `profileImage` onto the one it holds, as it does for a room.
*/
async function setProfileImage(imageName: string): Promise<void> {
const res = await call<{ success?: boolean }>(`${where().accounts}/account/me/profileimage`, {
method: 'PUT',
authed: true,
form: { imageName },
refusal: 'The accounts worker refused the image.',
})
if (res.success !== true) throw new Error('The accounts 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
@@ -2676,8 +2722,62 @@ function BlobUpload({
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png'])
/**
* The room's thumbnail, which is also the control that replaces it: clicking the picture
* opens the file picker, and the picked file uploads straight away.
* A picture that is also the control that replaces it: clicking it opens the file
* picker, and the picked file is handed to `onFile`. The room thumbnail and the profile
* avatar are both this, with different pictures and different workers behind them.
*
* Just the picture and its input — the upload itself, and where its result is shown,
* belong to the caller: the frame is exactly the picture's size (a circle for the
* avatar), so a message can't live inside it without stretching it.
*/
function ImageUpload({
src,
className,
imgClassName,
hint,
pending,
onFile,
}: {
src: string
/** Goes on the label, which is the clickable frame — the shape (card, circle) is its. */
className?: string
imgClassName: string
/** What the overlay says at rest; while uploading it says so instead. */
hint: string
pending: boolean
onFile: (file: File) => void
}) {
return (
// A label, so the whole picture is the file input's click target. The input is
// hidden visually rather than with `display: none`, which keeps it in the tab
// order — the focus ring is drawn on the label around it.
<label className={`image-upload ${className ?? ''}`} aria-busy={pending}>
<img className={imgClassName} src={src} alt="" />
<span className="image-upload-hint">{pending ? 'Uploading…' : hint}</span>
<input
type="file"
accept="image/jpeg,image/png"
disabled={pending}
onChange={(e) => {
const input = e.target
const file = input.files?.[0]
// Cleared straight away, so picking the same file again still fires a
// change; the File object stays readable after the input lets go of it.
input.value = ''
if (file) onFile(file)
}}
/>
</label>
)
}
/** Refuse anything but the two image types the server resizes (see IMAGE_TYPES). */
function requireImage(file: File): void {
if (!IMAGE_TYPES.has(file.type)) throw new Error('Choose a JPEG or PNG image.')
}
/**
* The room's thumbnail, as an {@link ImageUpload}, with the upload's result under it.
*
* 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
@@ -2701,46 +2801,71 @@ function RoomImageUpload({
const { pending, error, done, run } = useAction()
return (
<div className="room-hero-media">
{/* A label, so the whole picture is the file input's click target. The input is
hidden visually rather than with `display: none`, which keeps it in the tab
order — the focus ring is drawn on the label around it. */}
<label className="room-image-upload" aria-busy={pending}>
<img className="room-hero-img" src={src} alt="" />
<span className="room-image-upload-hint">
{pending ? 'Uploading…' : 'Click to upload image'}
</span>
<input
type="file"
accept="image/jpeg,image/png"
disabled={pending}
onChange={(e) => {
const input = e.target
const file = input.files?.[0]
if (!file) return
void run(async () => {
try {
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)
return 'Image replaced — it shows in game now.'
} finally {
// Cleared either way, so picking the same file again still fires a change.
input.value = ''
}
})
}}
/>
</label>
<div className="image-upload-media">
<ImageUpload
src={src}
imgClassName="room-hero-img"
hint="Click to upload image"
pending={pending}
onFile={(file) =>
void run(async () => {
requireImage(file)
const imageName = await uploadToStorage(file, FILE_TYPE_IMAGE)
await setRoomImage(roomId, imageName)
onImageChange(imageName)
return 'Image replaced — it shows in game now.'
})
}
/>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
</div>
)
}
/**
* The player's avatar, as an {@link ImageUpload}: the same two calls the game makes when
* a player changes their profile picture, uploaded as a saved image of the profile type
* and then set on the account. The game's own picker only offers photos taken in game;
* this one takes any JPEG or PNG.
*
* A fragment, laid out by the identity card it sits in: the avatar takes its place beside
* the name, and a failure wraps onto a row of its own below both (`identity-status`)
* rather than under the avatar, where it would widen the circle to the text's width.
* Success says nothing: the new picture appearing is the confirmation.
*/
function ProfileImageUpload({
account,
onChange,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
}) {
const { pending, error, run } = useAction()
return (
<>
<ImageUpload
src={`${where().img}/${account.profileImage}?width=256`}
className="identity-avatar-upload"
imgClassName="identity-avatar"
hint="Change"
pending={pending}
onFile={(file) =>
void run(async () => {
requireImage(file)
const imageName = await uploadProfileImage(file)
await setProfileImage(imageName)
onChange({ ...account, profileImage: imageName })
return ''
})
}
/>
{error && <p className="error identity-status">{error}</p>}
</>
)
}
/**
* 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,
@@ -3131,10 +3256,16 @@ function Dashboard({
return (
<>
<section className="card identity">
<div className="muted">Signed in as</div>
<div className="big">{account.displayName || account.username}</div>
<div className="handle">
@{account.username} · #{account.accountId} · {account.email ?? 'no email set'}
{/* The same picture the public profile draws (see PlayerPage), and here the way
to change it. Dashboard only renders once `/account/me` has answered, and
that call went through `where()`, so the img host is known. */}
<ProfileImageUpload account={account} onChange={onChange} />
<div className="identity-body">
<div className="muted">Signed in as</div>
<div className="big">{account.displayName || account.username}</div>
<div className="handle">
@{account.username} · #{account.accountId} · {account.email ?? 'no email set'}
</div>
</div>
</section>
<div className="workspace">
+65 -12
View File
@@ -783,18 +783,18 @@ h2 {
file input, so a click anywhere on it opens the picker. The hint is always drawn, not
only on hover — a touch screen has no hover, and nothing else says the picture can be
changed. */
.room-hero-media {
.image-upload-media {
display: flex;
flex-direction: column;
gap: 10px;
}
.room-hero-media .error,
.room-hero-media .ok {
.image-upload-media .error,
.image-upload-media .ok {
margin: 0;
}
.room-image-upload {
.image-upload {
position: relative;
display: block;
border-radius: 8px;
@@ -802,15 +802,63 @@ h2 {
cursor: pointer;
}
.room-image-upload[aria-busy='true'] {
.image-upload[aria-busy='true'] {
cursor: progress;
}
.room-image-upload .room-hero-img {
.image-upload img {
display: block;
}
.room-image-upload-hint {
/* The account page's avatar: the public profile's picture at the profile's size, with
the upload frame rounded to match so the hint sits as a chord across the bottom. */
.identity {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 18px;
}
/* The upload's result: a full row under the avatar and the name, never beside them. It
comes before the name in the DOM (the avatar control renders it), so `order` sorts it
after; without that the full-width row wraps the name down under it. */
.identity-status {
order: 1;
flex-basis: 100%;
margin: 0;
}
.identity-body {
min-width: 0;
}
/* `margin: 0` undoes the form `label` rule's bottom margin, which otherwise lifts the
avatar off the name's centre line. */
.identity-avatar-upload {
border-radius: 50%;
flex: none;
margin: 0;
/* The ring is the frame's, not the picture's: the frame clips its contents at the
inside of its border, which keeps the hint bar off the ring. On the picture, the bar
ran over it and read as a bar wider than the face. */
border: 3px solid var(--surface-hi);
}
.identity-avatar {
width: 96px;
height: 96px;
object-fit: cover;
background: var(--surface-hi);
}
/* A shallower bar than the room image's: on a 96px circle the default padding covers a
third of the face. */
.identity-avatar-upload .image-upload-hint {
padding: 2px 8px 5px;
font-size: 0.7rem;
}
.image-upload-hint {
position: absolute;
inset: auto 0 0;
padding: 6px 8px;
@@ -821,23 +869,28 @@ h2 {
transition: background 120ms;
}
.room-image-upload:hover .room-image-upload-hint,
.room-image-upload:focus-within .room-image-upload-hint {
.image-upload:hover .image-upload-hint,
.image-upload:focus-within .image-upload-hint {
background: rgb(0 0 0 / 0.8);
}
/* Clipped to nothing rather than `display: none`, which would take it out of the tab
order; the ring is drawn on the label instead. */
.room-image-upload input[type='file'] {
order; the ring is drawn on the label instead. Pinned to the frame's corner: left at its
static position it lands below the picture, outside the frame's clipped box, and the
browser scrolls the frame's content up to bring it into view when a click focuses it. */
.image-upload input[type='file'] {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
opacity: 0;
}
.room-image-upload:has(input:focus-visible) {
.image-upload:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}