From 9c4c1bdd3a70dc7d8ccc874e830aa6292a899ab7 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 25 Sep 2026 11:02:42 -0400 Subject: [PATCH] [www] pfp upload --- apps/www/src/client/App.tsx | 211 ++++++++++++++++++++++++++------- apps/www/src/client/styles.css | 77 ++++++++++-- 2 files changed, 236 insertions(+), 52 deletions(-) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 3fece29..3cbd353 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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 { } } +/** + * 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 { + 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 { + 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. + + ) +} + +/** 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 ( -
- {/* 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. */} - +
+ + 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 &&

{error}

} {done &&

{done}

}
) } +/** + * 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 ( + <> + + void run(async () => { + requireImage(file) + const imageName = await uploadProfileImage(file) + await setProfileImage(imageName) + onChange({ ...account, profileImage: imageName }) + return '' + }) + } + /> + {error &&

{error}

} + + ) +} + /** * 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 ( <>
-
Signed in as
-
{account.displayName || account.username}
-
- @{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. */} + +
+
Signed in as
+
{account.displayName || account.username}
+
+ @{account.username} · #{account.accountId} · {account.email ?? 'no email set'} +
diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 393c6f5..7dee904 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -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; }