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:
parent
a597425e45
commit
5e76b6176a
4 changed files with 64 additions and 0 deletions
|
|
@ -19,6 +19,16 @@ export async function uploadAvatar(file: File): Promise<{ avatar_url: string }>
|
||||||
return api.request('/avatars', { method: 'POST', body })
|
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. */
|
/** 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 }> {
|
export function uploadAvatarWithProgress(file: File, onProgress: (pct: number) => void): Promise<{ avatar_url: string }> {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
addAvatarHistory,
|
addAvatarHistory,
|
||||||
checkAvatarFile,
|
checkAvatarFile,
|
||||||
fetchCurrentAvatarAsFile,
|
fetchCurrentAvatarAsFile,
|
||||||
|
removeAvatar,
|
||||||
uploadAvatarWithProgress,
|
uploadAvatarWithProgress,
|
||||||
} from '@features/account/api'
|
} from '@features/account/api'
|
||||||
import { AvatarCropper } from './AvatarCropper'
|
import { AvatarCropper } from './AvatarCropper'
|
||||||
|
|
@ -23,6 +24,11 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File)
|
||||||
const [progress, setProgress] = useState<number | null>(null)
|
const [progress, setProgress] = useState<number | null>(null)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [historyTick, setHistoryTick] = useState(0)
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: (file: File) => uploadAvatarWithProgress(file, setProgress),
|
mutationFn: (file: File) => uploadAvatarWithProgress(file, setProgress),
|
||||||
onSuccess: async (_data, file) => {
|
onSuccess: async (_data, file) => {
|
||||||
|
|
@ -47,6 +53,12 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [done])
|
}, [done])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!armRemove) return
|
||||||
|
const timer = setTimeout(() => setArmRemove(false), 4000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [armRemove])
|
||||||
|
|
||||||
const validatePick = useCallback(
|
const validatePick = useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
const problem = checkAvatarFile(file, t)
|
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' }))
|
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) {
|
if (picked) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
|
@ -119,6 +150,21 @@ export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File)
|
||||||
{t('avatar.recrop')}
|
{t('avatar.recrop')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
{progress !== null && <ProgressBar pct={progress} />}
|
{progress !== null && <ProgressBar pct={progress} />}
|
||||||
{done ? <Notice tone="success">{t('avatar.uploaded')}</Notice> : null}
|
{done ? <Notice tone="success">{t('avatar.uploaded')}</Notice> : null}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ export const accountEn = {
|
||||||
zoom: 'Zoom',
|
zoom: 'Zoom',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
remove: 'Remove avatar',
|
||||||
|
removeConfirm: 'Really remove?',
|
||||||
|
removed: 'Avatar removed',
|
||||||
|
removeFailed: 'Could not remove the avatar.',
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
avatarType: 'PNG, JPG or WebP please.',
|
avatarType: 'PNG, JPG or WebP please.',
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ export const accountRu = {
|
||||||
zoom: 'Масштаб',
|
zoom: 'Масштаб',
|
||||||
confirm: 'Подтвердить',
|
confirm: 'Подтвердить',
|
||||||
cancel: 'Отмена',
|
cancel: 'Отмена',
|
||||||
|
remove: 'Удалить аватар',
|
||||||
|
removeConfirm: 'Точно удалить?',
|
||||||
|
removed: 'Аватар удалён',
|
||||||
|
removeFailed: 'Не удалось удалить аватар.',
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
avatarType: 'Подойдёт PNG, JPG или WebP.',
|
avatarType: 'Подойдёт PNG, JPG или WebP.',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue