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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export const accountRu = {
|
|||
zoom: 'Масштаб',
|
||||
confirm: 'Подтвердить',
|
||||
cancel: 'Отмена',
|
||||
remove: 'Удалить аватар',
|
||||
removeConfirm: 'Точно удалить?',
|
||||
removed: 'Аватар удалён',
|
||||
removeFailed: 'Не удалось удалить аватар.',
|
||||
},
|
||||
validation: {
|
||||
avatarType: 'Подойдёт PNG, JPG или WebP.',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue