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

@ -14,6 +14,7 @@ export type RequestOptions = { method?: string; json?: unknown; body?: BodyInit;
export type ApiClient = {
request<T>(path: string, options?: RequestOptions): Promise<T>
upload(path: string, body: FormData, onProgress?: (pct: number) => void): Promise<unknown>
refresh(): Promise<boolean>
setAccessToken(token: string | null): void
hasAccessToken(): boolean
@ -47,6 +48,8 @@ export function createApiClient(
return false
}
const body = (await res.json()) as { access_token: string }
// Store the renewed token: callers (session restore) rely on refresh()
// alone, and requests after a reload would otherwise go out without a bearer.
accessToken = body.access_token
return true
} catch {
@ -88,8 +91,52 @@ export function createApiClient(
return (text ? JSON.parse(text) : undefined) as T
}
/**
* Sends `body` via XMLHttpRequest so byte-level upload progress is observable —
* fetch exposes none. Same auth/refresh/error semantics as `request`.
*/
function upload(path: string, body: FormData, onProgress?: (pct: number) => void): Promise<unknown> {
return new Promise((resolve, reject) => {
const attempt = (retried: boolean) => {
const xhr = new XMLHttpRequest()
xhr.withCredentials = true
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress?.(Math.round((e.loaded / e.total) * 100))
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response ? JSON.parse(xhr.response) : undefined)
return
}
if (xhr.status === 401 && !retried) {
void refresh().then((ok) => (ok ? attempt(true) : reject(errorFrom(xhr))))
return
}
reject(errorFrom(xhr))
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.open('POST', `${baseUrl}${path}`)
if (accessToken) xhr.setRequestHeader('authorization', `Bearer ${accessToken}`)
xhr.send(body)
}
attempt(false)
})
}
function errorFrom(xhr: XMLHttpRequest): ApiError {
let message = `HTTP ${xhr.status}`
try {
const body: unknown = JSON.parse(xhr.response)
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') message = body.error
} catch {
// non-JSON error body: keep the status text
}
return new ApiError(xhr.status, message)
}
return {
request,
upload,
refresh,
setAccessToken: (token) => {
accessToken = token

View file

@ -0,0 +1,87 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { createApiClient } from '../client'
import { json, mockFetch } from '@test/fetch'
/** Minimal XMLHttpRequest stand-in: `send` hands the instance to the test to fire events on. */
class XhrStub {
static instances: XhrStub[] = []
method = ''
url = ''
headers: Record<string, string> = {}
withCredentials = false
status = 200
response = ''
upload: { onprogress?: (e: { lengthComputable: boolean; loaded: number; total: number }) => void } = {}
onload?: () => void
onerror?: () => void
open(method: string, url: string) {
this.method = method
this.url = url
}
setRequestHeader(name: string, value: string) {
this.headers[name.toLowerCase()] = value
}
send() {
XhrStub.instances.push(this)
}
}
beforeEach(() => {
XhrStub.instances = []
vi.stubGlobal('XMLHttpRequest', XhrStub)
})
test('upload sends bearer token, cookies and reports progress', async () => {
mockFetch({})
const api = createApiClient()
api.setAccessToken('tok')
const progress: number[] = []
const promise = api.upload('/avatars', new FormData(), (pct) => progress.push(pct))
const xhr = XhrStub.instances[0]
xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 })
xhr.upload.onprogress?.({ lengthComputable: false, loaded: 0, total: 0 })
xhr.status = 200
xhr.response = JSON.stringify({ avatar_url: 'x' })
xhr.onload?.()
await expect(promise).resolves.toEqual({ avatar_url: 'x' })
expect(progress).toEqual([50])
expect(xhr.url).toBe('/api/avatars')
expect(xhr.headers.authorization).toBe('Bearer tok')
expect(xhr.withCredentials).toBe(true)
})
test('upload retries once after a successful refresh on 401', async () => {
const fetch = mockFetch({ 'POST /auth/refresh': () => json({ access_token: 'new' }) })
const api = createApiClient()
const promise = api.upload('/avatars', new FormData())
const first = XhrStub.instances[0]
expect(first.headers.authorization).toBeUndefined()
first.status = 401
first.response = JSON.stringify({ error: 'unauthorized' })
first.onload?.()
const second = await vi.waitFor(() => {
expect(XhrStub.instances).toHaveLength(2)
return XhrStub.instances[1]
})
expect(second.headers.authorization).toBe('Bearer new')
second.status = 200
second.response = ''
second.onload?.()
await expect(promise).resolves.toBeUndefined()
expect(fetch.mock.calls.some(([url]) => String(url).includes('/auth/refresh'))).toBe(true)
})
test('upload rejects with the server error message when refresh fails', async () => {
mockFetch({ 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) })
const api = createApiClient()
const promise = api.upload('/avatars', new FormData())
const xhr = XhrStub.instances[0]
xhr.status = 413
xhr.response = JSON.stringify({ error: 'too large' })
xhr.onload?.()
await expect(promise).rejects.toMatchObject({ status: 413, message: 'too large' })
})

View file

@ -1,8 +1,8 @@
import { expect, test } from 'vitest'
import { initI18n } from '../../../app/i18n'
import { initI18n } from '@app/i18n'
import { ApiError, createApiClient } from '../client'
import { describeError } from '../errors'
import { json, mockFetch } from '../../../test/fetch'
import { json, mockFetch } from '@test/fetch'
const t = initI18n('ru').getFixedT('ru', 'common')

View file

@ -2,7 +2,7 @@ import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { BreakableBlock } from '../BreakableBlock'
import { renderApp } from '../../../test/render'
import { renderApp } from '@test/render'
// motion's useReducedMotion reads matchMedia, which jsdom lacks.
vi.stubGlobal('matchMedia', (query: string) => ({

View file

@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { expect, test } from 'vitest'
import { initI18n } from '../../../app/i18n'
import { initI18n } from '@app/i18n'
import { LanguageSwitch } from '../LanguageSwitch'
function Probe() {

View file

@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { DOWNLOAD_URL, GITHUB_REPO } from '../../features/download/api'
import { DOWNLOAD_URL, GITHUB_REPO } from '@features/download/api'
import { LanguageSwitch } from '../i18n/LanguageSwitch'
export function Footer() {

View file

@ -2,9 +2,9 @@ import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getI18n } from 'react-i18next'
import { afterEach, expect, test } from 'vitest'
import { routes } from '../../../app/routes'
import { renderApp } from '../../../test/render'
import { json, mockFetch } from '../../../test/fetch'
import { routes } from '@app/routes'
import { renderApp } from '@test/render'
import { json, mockFetch } from '@test/fetch'
import { PageTitle } from '../PageTitle'
afterEach(async () => {

View file

@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { useSession } from '../../../features/auth/session'
import { useSession } from '@features/auth/session'
import type { Me } from '../../types'
/** The pixel-heart mark, standalone (no badge background) — same shape as public/favicon.svg. */

View file

@ -1,7 +1,8 @@
import type { ReactNode } from 'react'
export function Notice({ tone, children }: { tone: 'info' | 'error'; children: ReactNode }) {
const styles = tone === 'error' ? 'border-ember/60 text-ember' : 'border-shoal text-frost'
export function Notice({ tone, children }: { tone: 'info' | 'error' | 'success'; children: ReactNode }) {
const styles =
tone === 'error' ? 'border-ember/60 text-ember' : tone === 'success' ? 'border-ice/60 text-ice' : 'border-shoal text-frost'
return (
<div role={tone === 'error' ? 'alert' : 'status'} className={`rounded-ctl border px-3 py-2 text-sm ${styles}`}>
{children}