-
Notifications
You must be signed in to change notification settings - Fork 14
feat: badge-linked profile avatars (TASK-22142) #2929
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
bc6b698
feat(avatars): add the avatar art and its manifest (TASK-22142)
abalinda 1bb0ea5
feat(avatars): wear the picked avatar, fall back to one username init…
abalinda a164fb9
feat(avatars): picker drawer with a die, badge-earned toast hands off…
abalinda 2df3f33
fix(avatar): own avatar shows the first letter of the username, not t…
abalinda a08058b
feat(avatars): stack the picker on the first-letter fallback (TASK-22…
abalinda 61ffd41
fix(avatars): palette by class, not inline style (ds-lint ratchet)
abalinda 5b27877
feat(avatars): art for the fifteen badges with more than 20 holders
abalinda b5a297d
fix(avatars): Support Survivor gets its own set
abalinda fefef4f
fix(avatars): review batch for #2929
abalinda 10f29ae
feat(avatars): one row of five basics, the dice redeals it (TASK-22142)
abalinda 38c5391
fix(avatars): the server's answer wins after a save burst (Chip, #2929)
abalinda 1898421
fix(avatars): Support Survivor has no set of its own
abalinda 80748c2
Merge remote-tracking branch 'origin/dev' into feat/22142-badge-avatars
abalinda 174df8f
fix(demo-api): keep the picked avatar between update-user and users/m…
abalinda 12442e8
feat(avatars): ship the sticker art set and the letter fallback (TASK…
0xkkonrad 60f9c09
Merge remote-tracking branch 'origin/dev' into feat/22142-badge-avatars
abalinda 7a9778d
fix(avatars): serialize saves, seed the home chip from the username, …
abalinda e7fbf49
fix(avatars): stickers sit plain on the page, no circle and no palett…
abalinda 427292f
fix(avatars): a tap during the closing refetch drains too
abalinda File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect, useRef, useState } from 'react' | ||
| import { useTranslations } from 'next-intl' | ||
| import { updateUserById } from '@/app/actions/users' | ||
| import { Button } from '@/components/0_Bruddle/Button' | ||
| import { useToast } from '@/components/0_Bruddle/Toast' | ||
| import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/Global/Drawer' | ||
| import { useAuth } from '@/context/authContext' | ||
| import { twMerge } from '@/utils/tw' | ||
| import { badgeAvatarKeys, offerBasics } from './avatar.utils' | ||
| import { roveAvatarTiles } from './avatarPicker.utils' | ||
| import { UserAvatar } from './UserAvatar' | ||
|
|
||
| interface AvatarPickerProps { | ||
| open: boolean | ||
| onOpenChange: (open: boolean) => void | ||
| } | ||
|
|
||
| /** | ||
| * The profile avatar picker (TASK-22142): what the user's badges unlocked, | ||
| * then one row of the basics everyone has. A tap saves at once; the dice | ||
| * rerolls the offered row and never the pick; "use my initial" clears it. | ||
| * The API validates the pick against the same pool, so a locked key never | ||
| * lands even if the manifest and the catalog drift. | ||
| */ | ||
| export function AvatarPicker({ open, onOpenChange }: AvatarPickerProps) { | ||
| const t = useTranslations('avatar') | ||
| const tCommon = useTranslations('common') | ||
| const { user, fetchUser } = useAuth() | ||
| const { toast } = useToast() | ||
|
|
||
| const userId = user?.user.userId | ||
| const username = user?.user.username ?? undefined | ||
| const saved = user?.user.avatarKey ?? null | ||
| const badges = user?.user.badges ?? [] | ||
| const held = badges.map((badge) => badge.code) | ||
| const badgeName = Object.fromEntries(badges.map((badge) => [badge.code, badge.name])) | ||
| const unlocked = badgeAvatarKeys(held) | ||
|
|
||
| // The tile moves on tap; the slot behind the drawer moves after fetchUser | ||
| // lands. `pending` overrides `saved` while writes are in flight. One | ||
| // refetch per burst, when the last request lands, and the server's | ||
| // answer wins: a failed older request cannot roll back a newer success, | ||
| // and a lone failure snaps back to what is saved. | ||
| const [pending, setPending] = useState<string | null | undefined>(undefined) | ||
| const inFlight = useRef(0) | ||
| const pick = pending === undefined ? saved : pending | ||
|
|
||
| const save = async (key: string | null) => { | ||
| if (!userId) return | ||
| setPending(key) | ||
| inFlight.current += 1 | ||
| const { error } = await updateUserById({ userId, avatarKey: key }) | ||
|
abalinda marked this conversation as resolved.
Outdated
abalinda marked this conversation as resolved.
Outdated
abalinda marked this conversation as resolved.
Outdated
|
||
| inFlight.current -= 1 | ||
| if (error) toast({ type: 'error', message: t('saveFailed') }) | ||
| if (inFlight.current === 0) { | ||
| await fetchUser() | ||
|
abalinda marked this conversation as resolved.
Outdated
abalinda marked this conversation as resolved.
Outdated
|
||
| setPending(undefined) | ||
| } | ||
| } | ||
|
|
||
| // the offered row of five basics: dealt on open, redealt by the dice | ||
| const [offer, setOffer] = useState<string[]>([]) | ||
| useEffect(() => { | ||
| if (open) setOffer(offerBasics(saved)) | ||
| // deal once per open; the pick joins the row by being picked from it | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [open]) | ||
| const rollDice = () => setOffer(offerBasics(pick)) | ||
|
|
||
| // human labels: "Bug Whisperer · beetle" for a badge avatar, the slug for a basic | ||
| const label = (key: string) => { | ||
| const [kind, code, slug] = key.split('.') | ||
| return kind === 'badge' ? `${badgeName[code] ?? code} · ${slug}` : code | ||
| } | ||
|
|
||
| const tiles = (keys: string[], groupLabel: string) => { | ||
| const focusIndex = Math.max(0, keys.indexOf(pick ?? '')) | ||
| return ( | ||
| <div | ||
| role="radiogroup" | ||
| aria-label={groupLabel} | ||
| className="grid grid-cols-5 gap-2" | ||
| onKeyDown={roveAvatarTiles} | ||
| > | ||
| {keys.map((key, index) => { | ||
| const checked = key === pick | ||
| return ( | ||
| <button | ||
| key={key} | ||
| type="button" | ||
| role="radio" | ||
| aria-checked={checked} | ||
| aria-label={label(key)} | ||
| tabIndex={index === focusIndex ? 0 : -1} | ||
| onClick={() => void save(key)} | ||
| className={twMerge( | ||
| 'flex min-h-11 items-center justify-center rounded-sm border border-border-disabled bg-background-default p-1 focus-visible:outline-[3px] focus-visible:outline-action-focus', | ||
| checked && 'border-2 border-border-default shadow-4' | ||
| )} | ||
| > | ||
| <UserAvatar name={username} avatarKey={key} size="small" /> | ||
| </button> | ||
| ) | ||
| })} | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <Drawer open={open} onOpenChange={onOpenChange}> | ||
| <DrawerContent className="p-4"> | ||
| <DrawerHeader className="p-0 pb-4 text-left"> | ||
| <DrawerTitle className="text-heading-s text-foreground-primary">{t('title')}</DrawerTitle> | ||
| <DrawerDescription>{t('description')}</DrawerDescription> | ||
| </DrawerHeader> | ||
| <div className="flex flex-col gap-6"> | ||
| <section className="flex flex-col gap-2"> | ||
| <div className="flex items-baseline justify-between text-label-m text-foreground-secondary uppercase"> | ||
| <span>{t('fromBadges')}</span> | ||
| {unlocked.length > 0 && ( | ||
| <span className="normal-case">{t('unlocked', { count: unlocked.length })}</span> | ||
| )} | ||
| </div> | ||
| {unlocked.length > 0 ? ( | ||
| tiles(unlocked, t('fromBadges')) | ||
| ) : ( | ||
| <p className="text-body-s text-foreground-secondary">{t('noBadgeAvatars')}</p> | ||
| )} | ||
| </section> | ||
| <section className="flex flex-col gap-2"> | ||
| <div className="text-label-m text-foreground-secondary uppercase">{t('basics')}</div> | ||
| {tiles(offer, t('basics'))} | ||
| </section> | ||
| <div className="flex flex-col gap-2"> | ||
| <Button variant="stroke" className="w-full" onClick={rollDice}> | ||
| {t('rollDice')} | ||
| </Button> | ||
| <Button variant="purple" className="w-full" onClick={() => onOpenChange(false)}> | ||
| {tCommon('done')} | ||
| </Button> | ||
| <Button variant="transparent" className="w-full" onClick={() => void save(null)}> | ||
| {t('useInitial')} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </DrawerContent> | ||
| </Drawer> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| 'use client' | ||
|
|
||
| import Image from 'next/image' | ||
| import { useTranslations } from 'next-intl' | ||
| import AvatarWithBadge from '@/components/Profile/AvatarWithBadge' | ||
| import { AVATAR_SIZE_CLASSES, type AvatarSize } from '@/components/Profile/avatar-size.consts' | ||
| import { twMerge } from '@/utils/tw' | ||
| import { avatarPaletteClass, avatarSrc, letterAvatarSrc } from './avatar.utils' | ||
|
|
||
| interface UserAvatarProps { | ||
| /** Display name: the source of the day-0 letter sticker and of the label. */ | ||
| name?: string | ||
| avatarKey?: string | null | ||
| size?: AvatarSize | ||
| className?: string | ||
| } | ||
|
|
||
| /** | ||
| * The user's own avatar (TASK-22142): the picked character on its palette | ||
| * triple. Without a pick, the first letter of the name as a sticker, keyed on | ||
| * the name so the colour is stable before anything is picked. A name that does | ||
| * not start with a-z falls through to the first-letter avatar, which stays the | ||
| * one place that renders a bare initial. | ||
| */ | ||
| export function UserAvatar({ name, avatarKey, size = 'extra-small', className }: UserAvatarProps) { | ||
| const t = useTranslations('common') | ||
| const picked = avatarKey ? avatarSrc(avatarKey) : null | ||
| const letter = picked ? null : letterAvatarSrc(name) | ||
| // the palette is keyed on whatever identifies the art: the pick, or the | ||
| // name behind the letter | ||
| const art = | ||
| picked && avatarKey | ||
| ? { src: picked, paletteKey: avatarKey } | ||
| : letter && name | ||
| ? { src: letter, paletteKey: name } | ||
| : null | ||
|
|
||
| if (!art) { | ||
| return name ? ( | ||
| <AvatarWithBadge size={size} name={name} firstLetterOnly className={className} /> | ||
| ) : ( | ||
| <AvatarWithBadge | ||
| size={size} | ||
| icon="user" | ||
| className={twMerge( | ||
| 'border border-avatar-yellow-border bg-avatar-yellow text-avatar-yellow-foreground', | ||
| className | ||
| )} | ||
| iconFillColor="var(--color-avatar-yellow-foreground)" | ||
| /> | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <span | ||
| {...(name | ||
| ? { role: 'img', 'aria-label': t('userAvatarAlt', { username: name }) } | ||
| : { 'aria-hidden': true })} | ||
| className={twMerge( | ||
| 'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border', | ||
| avatarPaletteClass(art.paletteKey), | ||
| AVATAR_SIZE_CLASSES[size], | ||
| className | ||
| )} | ||
| > | ||
| <Image src={art.src} alt="" width={96} height={96} unoptimized className="size-[82%]" /> | ||
| </span> | ||
| ) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.