feat(frontend): 'Remove avatar' button wired to DELETE /avatars

Part of the avatar-settings rebuild (research: every product except Figma
ships a remove button; Figma gets complaints for missing it). Backend DELETE
route shipped in the previous commit.

- api.ts: removeAvatar() calls DELETE /avatars. Cache invalidation lives in
  the caller so button-side side effects (progress state, history tick) can
  chain in the same handler.
- AvatarUpload: 'Удалить аватар' / 'Remove avatar' appears in the same row as
  'Change' and 'Recrop' when an avatar is set. Two-step inline confirm:
  first click arms and swaps the label to 'Точно удалить?' with an ember
  border, second click sends DELETE. The armed state auto-reverts after 4 s
  so a forgotten armed button never stays one click from destructive.
- i18n: avatar.remove / .removeConfirm / .removed / .removeFailed in ru+en.
  en.ts uses , so a missing key on
  either side would fail the type check.

Not yet: a real Modal with X / ESC / backdrop (comes with the AvatarEditDialog
chunk), preview-in-3-sizes, and cropper keyboard/wheel/pinch. Tests stay at
140/140, tsc and oxlint clean.
This commit is contained in:
loki5512344 2026-10-12 22:44:51 +02:00
parent a597425e45
commit 5e76b6176a
Signed by: boba
GPG key ID: 253067914055423B
4 changed files with 64 additions and 0 deletions

View file

@ -19,6 +19,16 @@ export async function uploadAvatar(file: File): Promise<{ avatar_url: string }>
return api.request('/avatars', { method: 'POST', body })
}
/**
* DELETE /avatars — clears the caller's picture server-side. The backend is
* idempotent (always 204), so a repeated click is safe. The `me` cache is
* invalidated by the caller, not here, so the button can chain invalidations
* with any other side effects (e.g. history tick bump).
*/
export async function removeAvatar(): Promise<void> {
await api.request('/avatars', { method: 'DELETE' })
}
/** Byte-level progress comes only from XHR (see `api.upload`), not from fetch. */
export function uploadAvatarWithProgress(file: File, onProgress: (pct: number) => void): Promise<{ avatar_url: string }> {
const body = new FormData()

View file

@ -7,6 +7,7 @@ import {
addAvatarHistory,
checkAvatarFile,
fetchCurrentAvatarAsFile,
removeAvatar,
uploadAvatarWithProgress,
} from '@features/account/api'
import { AvatarCropper } from './AvatarCropper'
@ -23,6 +24,11 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File)
const [progress, setProgress] = useState<number | null>(null)
const [done, setDone] = useState(false)
const [historyTick, setHistoryTick] = useState(0)
// Two-step confirm for "Remove avatar": first click flips `armRemove`, second
// click actually calls the API. `armRemove` reverts after a few seconds so a
// forgotten armed button does not stay one click away from destructive.
const [armRemove, setArmRemove] = useState(false)
const [removeBusy, setRemoveBusy] = useState(false)
const mutation = useMutation({
mutationFn: (file: File) => uploadAvatarWithProgress(file, setProgress),
onSuccess: async (_data, file) => {
@ -47,6 +53,12 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File)
return () => clearTimeout(timer)
}, [done])
useEffect(() => {
if (!armRemove) return
const timer = setTimeout(() => setArmRemove(false), 4000)
return () => clearTimeout(timer)
}, [armRemove])
const validatePick = useCallback(
(file: File) => {
const problem = checkAvatarFile(file, t)
@ -84,6 +96,25 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File)
mutation.mutate(new File([blob], 'avatar.png', { type: 'image/png' }))
}
async function onRemoveClick() {
if (!armRemove) {
setArmRemove(true)
return
}
setArmRemove(false)
setRemoveBusy(true)
setError(undefined)
try {
await removeAvatar()
client.invalidateQueries({ queryKey: ['me'] })
setDone(true)
} catch {
setError(t('avatar.removeFailed'))
} finally {
setRemoveBusy(false)
}
}
if (picked) {
return (
<div className="flex flex-col items-center gap-2">
@ -119,6 +150,21 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File)
{t('avatar.recrop')}
</button>
) : null}
{me?.avatar_url ? (
<button
type="button"
onClick={onRemoveClick}
disabled={removeBusy || mutation.isPending}
aria-busy={removeBusy || undefined}
className={`cursor-pointer rounded-ctl border px-3 py-1.5 text-sm transition-colors ${
armRemove
? 'border-ember text-ember hover:border-ember/80'
: 'border-shoal text-snow hover:border-ember/60 hover:text-ember'
}`}
>
{armRemove ? t('avatar.removeConfirm') : t('avatar.remove')}
</button>
) : null}
</div>
{progress !== null && <ProgressBar pct={progress} />}
{done ? <Notice tone="success">{t('avatar.uploaded')}</Notice> : null}

View file

@ -17,6 +17,10 @@ export const accountEn = {
zoom: 'Zoom',
confirm: 'Confirm',
cancel: 'Cancel',
remove: 'Remove avatar',
removeConfirm: 'Really remove?',
removed: 'Avatar removed',
removeFailed: 'Could not remove the avatar.',
},
validation: {
avatarType: 'PNG, JPG or WebP please.',

View file

@ -14,6 +14,10 @@ export const accountRu = {
zoom: 'Масштаб',
confirm: 'Подтвердить',
cancel: 'Отмена',
remove: 'Удалить аватар',
removeConfirm: 'Точно удалить?',
removed: 'Аватар удалён',
removeFailed: 'Не удалось удалить аватар.',
},
validation: {
avatarType: 'Подойдёт PNG, JPG или WebP.',