From eeb652fef7f1258af7e8449ce72655233cd392cd Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 26 Oct 2026 18:44:18 -0400 Subject: [PATCH] [www] banner upload --- apps/www/src/client/App.tsx | 91 ++++++++++++++++++++++++++++++++++ apps/www/src/client/styles.css | 28 +++++++++++ 2 files changed, 119 insertions(+) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 4a92268..fbe7508 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -74,6 +74,8 @@ interface SelfAccount { email: string | null /** A key on the `img` worker — `DefaultProfileImage.jpg` until the player sets one. */ profileImage: string + /** The profile banner's key on `img` — `""` until the player sets one. */ + bannerImage: 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 @@ -364,6 +366,41 @@ async function setProfileImage(imageName: string): Promise { if (res.success !== true) throw new Error('The accounts worker refused the image.') } +/** + * Upload a banner picture through the same `api` route as the avatar, but typed + * `SavedImageType.None` (0): the game has no banner type (its banner is one of the + * player's camera photos), and anything but ShareCamera stays out of the photo feed and + * the player's photo list, while ProfileThumbnail would also overwrite the avatar. Filed + * under `none/` in the bucket. Returns the key `img` serves it by. + */ +async function uploadBannerImage(file: File): Promise { + const form = new FormData() + form.set('imgMeta', JSON.stringify({ savedImageType: 0 })) + 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's banner at an uploaded picture — `PUT /account/me/bannerimage` on + * `accounts`, the call the game makes when a player picks a banner. Same `{ success }` + * envelope as the avatar's PUT, and it pushes the same `AccountUpdate` frame, so a + * running client redraws the banner too. + */ +async function setBannerImage(imageName: string): Promise { + const res = await call<{ success?: boolean }>(`${where().accounts}/account/me/bannerimage`, { + method: 'PUT', + authed: true, + form: { imageName }, + refusal: 'The accounts worker refused the banner.', + }) + if (res.success !== true) throw new Error('The accounts worker refused the banner.') +} + /** * 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 @@ -4317,6 +4354,11 @@ function Dashboard({ // settings form they opened the page to avoid. { id: 'rooms', label: 'My rooms', render: () => }, { id: 'photos', label: 'My photos', render: () => }, + { + id: 'banner', + label: 'Profile banner', + render: () => , + }, { id: 'username', label: 'Username', @@ -4495,6 +4537,55 @@ function MyPhotos({ accountId }: { accountId: number }) { ) } +/** + * The profile banner, as an {@link ImageUpload} in the profile banner's own shape: upload + * the picture (`uploadBannerImage`), then set it on the account (`setBannerImage`). The + * game only lets a player pick one of their camera photos; this takes any JPEG or PNG. + * The held account is patched with the new key, so the preview redraws without a refetch. + */ +function BannerUpload({ + account, + onChange, +}: { + account: SelfAccount + onChange: (a: SelfAccount) => void +}) { + const { pending, error, done, run } = useAction() + + return ( +
+

Profile banner

+

+ The wide picture across the top of your profile. Click it to upload a JPEG or PNG. +

+ + void run(async () => { + requireImage(file) + const imageName = await uploadBannerImage(file) + await setBannerImage(imageName) + onChange({ ...account, bannerImage: imageName }) + return 'Banner updated.' + }) + } + /> + {error &&

{error}

} + {done &&

{done}

} +
+ ) +} + /** * One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's * doing. The whole row links to the room's own page. diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 22f954e..17dd36e 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -1894,6 +1894,34 @@ button[type='submit']:disabled { flex-wrap: wrap; } +/* The account page's banner upload, in the profile banner's shape so what's uploaded + looks as it will across the top of the profile. */ +.banner-upload { + display: block; + margin: 12px 0 0; + border-radius: 9px; + border: 1px solid var(--line); +} + +.banner-preview { + width: 100%; + height: 220px; + object-fit: cover; + background: + radial-gradient( + circle at 80% 20%, + color-mix(in srgb, var(--accent) 24%, transparent), + transparent 45% + ), + linear-gradient(135deg, var(--surface-hi), var(--surface)); +} + +@media (max-width: 640px) { + .banner-preview { + height: 140px; + } +} + /* ---- Public player profiles -------------------------------------------- */ .player-hero {