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.',