feat(frontend): avatar upload QOL — feedback, re-crop, drop, progress, history

Task 12 batch around the existing crop/preview flow:
- Success Notice (auto-dismiss ~2.5s) after an avatar upload.
- "Reposition" re-crops the current avatar without re-picking a file.
- Drag & drop an image straight onto the profile avatar circle.
- Real upload progress via a new XHR-backed api.upload (fetch has none);
  refreshOnce now also stores the renewed token (reload-restore fix).
- Local avatar history (last 5, IndexedDB, this-device-only) strip that
  re-uploads a stored crop. Stored as ArrayBuffer (Blob is not cloneable
  under structuredClone); label makes the per-device scope explicit.
- Added a "success" tone to Notice.

Infra (same session): registered @shared/@features/@pages/@app/@test path
aliases in vite.config + tsconfigs and rewrote deep ../../../ imports;
moved avatar components into components/avatar/ to keep folders at <=4 files.

Tests: avatarHistory cap/order, recrop visibility, drop-opens-cropper,
client upload/progress/401-retry. bun test 103 pass, lint 0 errors, build ok.
This commit is contained in:
loki5512344 2026-09-28 22:56:01 +02:00
parent e79b0aedd2
commit fd2e29cede
Signed by: boba
GPG key ID: 253067914055423B
92 changed files with 808 additions and 287 deletions

View file

@ -1,4 +1,4 @@
import { api } from '../../shared/api/client'
import { api } from '@shared/api/client'
import type { TFunction } from 'i18next'
export type DeviceLink = { id: string; linked_at: string; last_seen: string | null }
@ -19,6 +19,81 @@ export async function uploadAvatar(file: File): Promise<{ avatar_url: string }>
return api.request('/avatars', { method: 'POST', body })
}
/** 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()
body.set('file', file)
return api.upload('/avatars', body, onProgress) as Promise<{ avatar_url: string }>
}
/** Downloads the current avatar so it can be re-cropped without re-picking the source file. */
export async function fetchCurrentAvatarAsFile(url: string): Promise<File> {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return new File([await res.blob()], 'avatar.png', { type: 'image/png' })
}
const DB_NAME = 'lovisual-avatars'
const STORE = 'history'
const HISTORY_LIMIT = 5
export type AvatarHistoryEntry = { id: number; blob: Blob; storedAt: string }
// IndexedDB structured-clone does not reliably persist a Blob (Node/fake-indexeddb drop it), so the
// PNG is stored as an ArrayBuffer — universally cloneable — and rewrapped as a Blob on read.
type AvatarRecord = { id: number; data: ArrayBuffer; storedAt: string }
function openAvatarDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const open = indexedDB.open(DB_NAME, 1)
open.onupgradeneeded = () => {
if (!open.result.objectStoreNames.contains(STORE)) open.result.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true })
}
open.onsuccess = () => resolve(open.result)
open.onerror = () => reject(open.error)
})
}
function readAll(db: IDBDatabase): Promise<AvatarRecord[]> {
return new Promise((resolve, reject) => {
const req = db.transaction(STORE).objectStore(STORE).getAll()
req.onsuccess = () => resolve(req.result as AvatarRecord[])
req.onerror = () => reject(req.error)
})
}
/** Stores a cropped avatar locally (this device only) and trims the history to the newest few. */
export async function addAvatarHistory(blob: Blob): Promise<void> {
const db = await openAvatarDb()
const data = await blob.arrayBuffer()
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite')
tx.objectStore(STORE).put({ data, storedAt: new Date().toISOString() })
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
// Same-ms inserts are indistinguishable by timestamp, so keep the last 5 by insertion order.
const records = await readAll(db)
const stale = records.slice(0, Math.max(0, records.length - HISTORY_LIMIT))
if (stale.length === 0) return
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite')
for (const r of stale) tx.objectStore(STORE).delete(r.id)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
/** Newest-first history. */
export async function listAvatarHistory(): Promise<AvatarHistoryEntry[]> {
const db = await openAvatarDb()
const records = await readAll(db)
return records
.sort((a, b) => b.storedAt.localeCompare(a.storedAt) || b.id - a.id)
.slice(0, HISTORY_LIMIT)
.map(({ id, data, storedAt }) => ({ id, storedAt, blob: new Blob([data], { type: 'image/png' }) }))
}
export async function listDevices(): Promise<DeviceLink[]> {
return api.request('/device/links')
}

View file

@ -1,68 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Notice } from '../../../shared/ui/Notice'
import { checkAvatarFile, uploadAvatar } from '../api'
import { AvatarCropper } from './AvatarCropper'
/** A label-based file picker (keyboard reachable) that opens a crop step before uploading and refreshing the cached profile. */
export function AvatarUpload() {
const { t } = useTranslation('account')
const id = useId()
const client = useQueryClient()
const [error, setError] = useState<string>()
const [picked, setPicked] = useState<File>()
const mutation = useMutation({
mutationFn: uploadAvatar,
onSuccess: () => {
client.invalidateQueries({ queryKey: ['me'] })
setPicked(undefined)
},
})
function onChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
const problem = checkAvatarFile(file, t)
if (problem) {
setError(problem)
return
}
setError(undefined)
setPicked(file)
}
function onConfirmCrop(blob: Blob) {
const cropped = new File([blob], 'avatar.png', { type: 'image/png' })
mutation.mutate(cropped)
}
if (picked) {
return (
<div className="flex flex-col items-center gap-2">
<AvatarCropper file={picked} onCancel={() => setPicked(undefined)} onConfirm={onConfirmCrop} />
{error ? <Notice tone="error">{error}</Notice> : null}
</div>
)
}
return (
<div className="flex flex-col items-center gap-2">
<label
htmlFor={id}
className="cursor-pointer rounded-ctl border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
>
{t('avatar.change')}
<input
id={id}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={onChange}
/>
</label>
{error ? <Notice tone="error">{error}</Notice> : null}
</div>
)
}

View file

@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { Button } from '@shared/ui/Button'
import { listDevices, revokeDevice, type DeviceLink } from '../api'
function relativeDate(iso: string): string {

View file

@ -1,14 +1,22 @@
import { useCallback, useRef, useState, type DragEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { Button } from '../../../shared/ui/Button'
import { useSession } from '../../auth/session'
import { AvatarUpload } from './AvatarUpload'
import { Button } from '@shared/ui/Button'
import { useSession } from '@features/auth/session'
import { AvatarUpload } from './avatar/AvatarUpload'
/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. */
/** The mod's profile panel: big avatar with an accent ring, nick, email and join date. The avatar circle doubles as a file drop target. */
export function ProfileCard() {
const { t } = useTranslation('account')
const { me, signOut } = useSession()
const navigate = useNavigate()
const [dragging, setDragging] = useState(false)
// AvatarUpload owns pick/cropper state; the drop handler just forwards the file into its validation path.
const acceptFile = useRef<(file: File) => void>(() => {})
// Stable handlers so AvatarUpload's expose-effect does not re-run on every parent render.
const handleReady = useCallback((accept: (file: File) => void) => {
acceptFile.current = accept
}, [])
if (!me) return null
@ -19,9 +27,25 @@ export function ProfileCard() {
navigate('/')
}
function onDrop(e: DragEvent<HTMLDivElement>) {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files?.[0]
if (file) acceptFile.current(file)
}
return (
<div className="flex flex-col items-center gap-4 rounded-(--radius-panel) border border-shoal bg-slate/70 p-6 text-center">
<div className="relative">
<div
data-testid="avatar-drop"
className={`relative rounded-full ${dragging ? 'ring-2 ring-ice' : ''}`}
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
<div className="flex h-24 w-24 items-center justify-center overflow-hidden rounded-full border-2 border-ice bg-abyss font-display text-3xl">
{me.avatar_url ? (
<img src={me.avatar_url} alt="" className="h-full w-full object-cover" />
@ -30,7 +54,7 @@ export function ProfileCard() {
)}
</div>
</div>
<AvatarUpload />
<AvatarUpload onReady={handleReady} />
<div>
<p className="font-display text-xl">{me.display_nick}</p>
<p className="text-sm text-frost">{me.email}</p>

View file

@ -1,6 +1,6 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../../../shared/ui/Button'
import { Button } from '@shared/ui/Button'
import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath'
const VIEWPORT = 240

View file

@ -0,0 +1,60 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listAvatarHistory, type AvatarHistoryEntry } from '@features/account/api'
type Props = {
/** Bumped by the parent after each successful upload so the store is re-read. */
tick: number
pending: boolean
onPick: (blob: Blob) => void
}
/** The last few avatars cropped on this device (IndexedDB only, not the account's full history). */
export function AvatarHistoryStrip({ tick, pending, onPick }: Props) {
const { t } = useTranslation('account')
const [entries, setEntries] = useState<AvatarHistoryEntry[]>([])
useEffect(() => {
let active = true
listAvatarHistory()
.then((list) => active && setEntries(list))
.catch(() => active && setEntries([]))
return () => {
active = false
}
}, [tick])
if (entries.length === 0) return null
return (
<div className="flex flex-col items-center gap-1 pt-1">
<p className="text-xs text-frost">{t('avatar.historyHint')}</p>
<ul className="flex list-none gap-2">
{entries.map((entry) => (
<li key={entry.id}>
<HistoryThumb entry={entry} disabled={pending} onPick={onPick} />
</li>
))}
</ul>
</div>
)
}
function HistoryThumb({ entry, disabled, onPick }: { entry: AvatarHistoryEntry; disabled: boolean; onPick: (blob: Blob) => void }) {
const { t } = useTranslation('account')
const url = useMemo(() => URL.createObjectURL(entry.blob), [entry])
useEffect(() => () => URL.revokeObjectURL(url), [url])
return (
<button
type="button"
disabled={disabled}
title={t('avatar.useHistory')}
aria-label={t('avatar.useHistory')}
onClick={() => onPick(entry.blob)}
className="h-10 w-10 cursor-pointer overflow-hidden rounded-full border border-shoal opacity-80 hover:border-ice hover:opacity-100 disabled:cursor-default"
>
{url ? <img src={url} alt="" className="h-full w-full object-cover" /> : null}
</button>
)
}

View file

@ -0,0 +1,145 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useId, useState, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Notice } from '@shared/ui/Notice'
import { useSession } from '@features/auth/session'
import {
addAvatarHistory,
checkAvatarFile,
fetchCurrentAvatarAsFile,
uploadAvatarWithProgress,
} from '@features/account/api'
import { AvatarCropper } from './AvatarCropper'
import { AvatarHistoryStrip } from './AvatarHistoryStrip'
/** Picker + drop target + history strip: validates a file, opens the cropper, uploads it with progress and confirms in place. */
export function AvatarUpload({ onReady }: { onReady?: (acceptFile: (file: File) => void) => void } = {}) {
const { t } = useTranslation('account')
const { me } = useSession()
const id = useId()
const client = useQueryClient()
const [error, setError] = useState<string>()
const [picked, setPicked] = useState<File>()
const [progress, setProgress] = useState<number | null>(null)
const [done, setDone] = useState(false)
const [historyTick, setHistoryTick] = useState(0)
const mutation = useMutation({
mutationFn: (file: File) => uploadAvatarWithProgress(file, setProgress),
onSuccess: (_data, file) => {
client.invalidateQueries({ queryKey: ['me'] })
void addAvatarHistory(file)
setHistoryTick((n) => n + 1)
setPicked(undefined)
setProgress(null)
setDone(true)
},
onError: (err: Error) => {
setProgress(null)
setError(err.message)
},
})
useEffect(() => {
if (!done) return
const timer = setTimeout(() => setDone(false), 2500)
return () => clearTimeout(timer)
}, [done])
const validatePick = useCallback(
(file: File) => {
const problem = checkAvatarFile(file, t)
if (problem) {
setError(problem)
return
}
setError(undefined)
setPicked(file)
},
[t],
)
// Hand the validate+open-cropper path to ProfileCard's drop target without duplicating it.
useEffect(() => {
onReady?.(validatePick)
}, [onReady, validatePick])
function onChange(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (file) validatePick(file)
}
async function recropCurrent() {
if (!me?.avatar_url) return
try {
setPicked(await fetchCurrentAvatarAsFile(me.avatar_url))
setError(undefined)
} catch {
setError(t('avatar.fetchFailed'))
}
}
function onConfirmCrop(blob: Blob) {
mutation.mutate(new File([blob], 'avatar.png', { type: 'image/png' }))
}
if (picked) {
return (
<div className="flex flex-col items-center gap-2">
<AvatarCropper file={picked} onCancel={() => setPicked(undefined)} onConfirm={onConfirmCrop} />
{progress !== null && <ProgressBar pct={progress} />}
{error ? <Notice tone="error">{error}</Notice> : null}
</div>
)
}
return (
<div className="flex flex-col items-center gap-2">
<div className="flex flex-wrap items-center justify-center gap-2">
<label
htmlFor={id}
className="cursor-pointer rounded-ctl border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
>
{t('avatar.change')}
<input
id={id}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={onChange}
/>
</label>
{me?.avatar_url ? (
<button
type="button"
onClick={recropCurrent}
className="cursor-pointer rounded-ctl border border-shoal px-3 py-1.5 text-sm text-snow hover:border-frost"
>
{t('avatar.recrop')}
</button>
) : null}
</div>
{progress !== null && <ProgressBar pct={progress} />}
{done ? <Notice tone="success">{t('avatar.uploaded')}</Notice> : null}
{error ? <Notice tone="error">{error}</Notice> : null}
<AvatarHistoryStrip
tick={historyTick}
pending={mutation.isPending}
onPick={(blob) => mutation.mutate(new File([blob], 'avatar.png', { type: 'image/png' }))}
/>
</div>
)
}
function ProgressBar({ pct }: { pct: number }) {
return (
<div
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
className="h-1 w-full max-w-[240px] overflow-hidden rounded-full bg-shoal"
>
<div className="h-full bg-ice transition-[width] duration-150" style={{ width: `${pct}%` }} />
</div>
)
}

View file

@ -1,4 +1,4 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { Translation } from '@shared/i18n/common.ru'
import type { accountRu } from './ru'
export const accountEn = {
@ -8,6 +8,11 @@ export const accountEn = {
},
avatar: {
change: 'Change avatar',
recrop: 'Reposition',
uploaded: 'Avatar updated',
fetchFailed: 'Could not load the current avatar.',
historyHint: 'Recent on this device',
useHistory: 'Restore this avatar',
reposition: 'Drag to reposition',
zoom: 'Zoom',
confirm: 'Confirm',

View file

@ -5,6 +5,11 @@ export const accountRu = {
},
avatar: {
change: 'Сменить аватар',
recrop: 'Перекадрировать',
uploaded: 'Аватар обновлён',
fetchFailed: 'Не удалось загрузить текущий аватар.',
historyHint: 'Последние на этом устройстве',
useHistory: 'Вернуть этот аватар',
reposition: 'Перетащи, чтобы выбрать область',
zoom: 'Масштаб',
confirm: 'Подтвердить',

View file

@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event'
import { getI18n } from 'react-i18next'
import { expect, test } from 'vitest'
import { checkAvatarFile } from '../api'
import { DeviceList } from '../components/DeviceList'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
import { DeviceList } from '@features/account/components/DeviceList'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
test('avatar file checks', () => {
const t = getI18n().getFixedT('ru', 'account')

View file

@ -0,0 +1,16 @@
import 'fake-indexeddb/auto'
import { beforeEach, expect, test } from 'vitest'
import { addAvatarHistory, listAvatarHistory } from '../api'
beforeEach(async () => {
const { indexedDB } = globalThis
indexedDB.deleteDatabase('lovisual-avatars')
})
test('keeps only the 5 most recent entries, newest first', async () => {
for (let i = 0; i < 7; i++) await addAvatarHistory(new Blob([String(i)], { type: 'image/png' }))
const entries = await listAvatarHistory()
expect(entries).toHaveLength(5)
expect(await entries[0].blob.text()).toBe('6')
expect(await entries[4].blob.text()).toBe('2')
})

View file

@ -1,9 +1,19 @@
import { screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { AvatarUpload } from '../components/AvatarUpload'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
import { AvatarUpload } from '@features/account/components/avatar/AvatarUpload'
import { ProfileCard } from '@features/account/components/ProfileCard'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
const me = (avatar_url: string | null) => ({
id: '1',
email: 'a@b.c',
display_nick: 'Rider',
role: 'user',
avatar_url,
created_at: '2026-01-01T00:00:00Z',
})
beforeEach(() => {
URL.createObjectURL = vi.fn(() => 'blob:mock')
@ -45,3 +55,34 @@ test('cancelling the crop step returns to the picker without uploading', async (
expect(await screen.findByText('Сменить аватар')).toBeInTheDocument()
expect(fetch.mock.calls.some(([url]) => String(url).includes('/avatars'))).toBe(false)
})
test('the reposition button is hidden when no avatar is set', async () => {
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json(me(null)),
})
renderApp([{ path: '/', element: <AvatarUpload /> }])
expect(await screen.findByText('Сменить аватар')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Перекадрировать' })).not.toBeInTheDocument()
})
test('the reposition button shows when an avatar is set', async () => {
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json(me('https://cdn/avatar.png')),
})
renderApp([{ path: '/', element: <AvatarUpload /> }])
expect(await screen.findByRole('button', { name: 'Перекадрировать' })).toBeInTheDocument()
})
test('dropping a file on the avatar circle opens the crop step', async () => {
mockFetch({
'POST /auth/refresh': () => json({ access_token: 't' }),
'GET /me': () => json(me(null)),
})
renderApp([{ path: '/', element: <ProfileCard /> }])
const target = await screen.findByTestId('avatar-drop')
fireEvent.drop(target, { dataTransfer: { files: [new File(['x'], 'a.png', { type: 'image/png' })] } })
expect(await screen.findByRole('button', { name: 'Подтвердить' })).toBeInTheDocument()
})

View file

@ -1,5 +1,5 @@
import { expect, test } from 'vitest'
import { clampPan, clampZoom, coverScale, sourceRect } from '../components/cropMath'
import { clampPan, clampZoom, coverScale, sourceRect } from '@features/account/components/avatar/cropMath'
test('coverScale scales the shorter side to fill the viewport', () => {
expect(coverScale(1000, 2000, 200)).toBeCloseTo(0.2)