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:
parent
e79b0aedd2
commit
fd2e29cede
92 changed files with 808 additions and 287 deletions
|
|
@ -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
|
||||
|
|
|
|||
87
frontend/src/shared/api/tests/client-upload.test.ts
Normal file
87
frontend/src/shared/api/tests/client-upload.test.ts
Normal 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' })
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue