From 5e76b6176ac7ef04c6a147f89297751377124017 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 12 Oct 2026 22:44:51 +0200 Subject: [PATCH] feat(frontend): 'Remove avatar' button wired to DELETE /avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/features/account/api.ts | 10 ++++ .../components/avatar/AvatarUpload.tsx | 46 +++++++++++++++++++ frontend/src/features/account/i18n/en.ts | 4 ++ frontend/src/features/account/i18n/ru.ts | 4 ++ 4 files changed, 64 insertions(+) diff --git a/frontend/src/features/account/api.ts b/frontend/src/features/account/api.ts index 26a89967..3f0ca5d0 100644 --- a/frontend/src/features/account/api.ts +++ b/frontend/src/features/account/api.ts @@ -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 { + 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() diff --git a/frontend/src/features/account/components/avatar/AvatarUpload.tsx b/frontend/src/features/account/components/avatar/AvatarUpload.tsx index 2549e789..13d9ab68 100644 --- a/frontend/src/features/account/components/avatar/AvatarUpload.tsx +++ b/frontend/src/features/account/components/avatar/AvatarUpload.tsx @@ -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(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 (
@@ -119,6 +150,21 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File) {t('avatar.recrop')} ) : null} + {me?.avatar_url ? ( + + ) : null}
{progress !== null && } {done ? {t('avatar.uploaded')} : null} diff --git a/frontend/src/features/account/i18n/en.ts b/frontend/src/features/account/i18n/en.ts index 5990dd15..329ec84e 100644 --- a/frontend/src/features/account/i18n/en.ts +++ b/frontend/src/features/account/i18n/en.ts @@ -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.', diff --git a/frontend/src/features/account/i18n/ru.ts b/frontend/src/features/account/i18n/ru.ts index 8f4d1e97..3c011ea0 100644 --- a/frontend/src/features/account/i18n/ru.ts +++ b/frontend/src/features/account/i18n/ru.ts @@ -14,6 +14,10 @@ export const accountRu = { zoom: 'Масштаб', confirm: 'Подтвердить', cancel: 'Отмена', + remove: 'Удалить аватар', + removeConfirm: 'Точно удалить?', + removed: 'Аватар удалён', + removeFailed: 'Не удалось удалить аватар.', }, validation: { avatarType: 'Подойдёт PNG, JPG или WebP.',