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
|
|
@ -0,0 +1,124 @@
|
|||
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@shared/ui/Button'
|
||||
import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath'
|
||||
|
||||
const VIEWPORT = 240
|
||||
const OUTPUT_SIZE = 512
|
||||
|
||||
type Props = {
|
||||
file: File
|
||||
onCancel: () => void
|
||||
onConfirm: (blob: Blob) => void
|
||||
}
|
||||
|
||||
/** Drag-to-reposition, zoom-to-scale circular crop UI. Renders the selection onto an offscreen canvas on confirm. */
|
||||
export function AvatarCropper({ file, onCancel, onConfirm }: Props) {
|
||||
const { t } = useTranslation('account')
|
||||
const [url] = useState(() => URL.createObjectURL(file))
|
||||
const [natural, setNatural] = useState<{ w: number; h: number }>()
|
||||
const [zoom, setZoom] = useState(MIN_ZOOM)
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const drag = useRef<{ x: number; y: number; offset: { x: number; y: number } } | null>(null)
|
||||
|
||||
function onImgLoad(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })
|
||||
}
|
||||
|
||||
function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
drag.current = { x: e.clientX, y: e.clientY, offset }
|
||||
}
|
||||
|
||||
function onPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!drag.current || !natural) return
|
||||
const dx = e.clientX - drag.current.x
|
||||
const dy = e.clientY - drag.current.y
|
||||
const next = { x: drag.current.offset.x + dx, y: drag.current.offset.y + dy }
|
||||
setOffset(clampPan(next, natural.w, natural.h, VIEWPORT, zoom))
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
drag.current = null
|
||||
}
|
||||
|
||||
function onZoomChange(next: number) {
|
||||
const clamped = clampZoom(next)
|
||||
setZoom(clamped)
|
||||
if (natural) setOffset((prev) => clampPan(prev, natural.w, natural.h, VIEWPORT, clamped))
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!natural) return
|
||||
setBusy(true)
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
await img.decode()
|
||||
const { sx, sy, sw, sh } = sourceRect(natural.w, natural.h, VIEWPORT, zoom, offset)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = OUTPUT_SIZE
|
||||
canvas.height = OUTPUT_SIZE
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE)
|
||||
canvas.toBlob((blob) => {
|
||||
setBusy(false)
|
||||
if (blob) onConfirm(blob)
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
const scale = natural ? coverScale(natural.w, natural.h, VIEWPORT) * zoom : 1
|
||||
const drawnW = natural ? natural.w * scale : VIEWPORT
|
||||
const drawnH = natural ? natural.h * scale : VIEWPORT
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-full border border-shoal touch-none"
|
||||
style={{ width: VIEWPORT, height: VIEWPORT, cursor: 'grab' }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={onImgLoad}
|
||||
className="pointer-events-none absolute select-none"
|
||||
style={{
|
||||
width: drawnW,
|
||||
height: drawnH,
|
||||
left: VIEWPORT / 2 - drawnW / 2 + offset.x,
|
||||
top: VIEWPORT / 2 - drawnH / 2 + offset.y,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex w-full max-w-[240px] flex-col gap-1 text-sm text-snow">
|
||||
{t('avatar.zoom')}
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_ZOOM}
|
||||
max={MAX_ZOOM}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => onZoomChange(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-shoal">{t('avatar.reposition')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="quiet" onClick={onCancel} disabled={busy}>
|
||||
{t('avatar.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={confirm} busy={busy} disabled={!natural}>
|
||||
{t('avatar.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
145
frontend/src/features/account/components/avatar/AvatarUpload.tsx
Normal file
145
frontend/src/features/account/components/avatar/AvatarUpload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
frontend/src/features/account/components/avatar/cropMath.ts
Normal file
60
frontend/src/features/account/components/avatar/cropMath.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/** Pure geometry for the circular avatar cropper. No DOM/canvas here so it's plain-arithmetic testable. */
|
||||
|
||||
export const MIN_ZOOM = 1
|
||||
export const MAX_ZOOM = 4
|
||||
|
||||
/** The zoom that makes the image exactly cover a `viewport`x`viewport` square at zoom=1 (our baseline). */
|
||||
export function coverScale(naturalWidth: number, naturalHeight: number, viewport: number): number {
|
||||
return viewport / Math.min(naturalWidth, naturalHeight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a pan offset (in viewport pixels, center-relative) so the scaled image never
|
||||
* shows empty space inside the `viewport`x`viewport` crop square.
|
||||
*/
|
||||
export function clampPan(
|
||||
offset: { x: number; y: number },
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
viewport: number,
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
const scale = coverScale(naturalWidth, naturalHeight, viewport) * zoom
|
||||
const scaledW = naturalWidth * scale
|
||||
const scaledH = naturalHeight * scale
|
||||
const maxX = Math.max(0, (scaledW - viewport) / 2)
|
||||
const maxY = Math.max(0, (scaledH - viewport) / 2)
|
||||
return {
|
||||
x: Math.min(maxX, Math.max(-maxX, offset.x)),
|
||||
y: Math.min(maxY, Math.max(-maxY, offset.y)),
|
||||
}
|
||||
}
|
||||
|
||||
export function clampZoom(zoom: number): number {
|
||||
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom))
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the image's natural size, the crop viewport size, the current pan offset (viewport px,
|
||||
* center-relative) and zoom, compute the source rectangle (in natural image pixels) that should
|
||||
* be drawn onto the output canvas.
|
||||
*/
|
||||
export function sourceRect(
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
viewport: number,
|
||||
zoom: number,
|
||||
offset: { x: number; y: number },
|
||||
): { sx: number; sy: number; sw: number; sh: number } {
|
||||
const scale = coverScale(naturalWidth, naturalHeight, viewport) * zoom
|
||||
const sw = viewport / scale
|
||||
const sh = viewport / scale
|
||||
const centerX = naturalWidth / 2 - offset.x / scale
|
||||
const centerY = naturalHeight / 2 - offset.y / scale
|
||||
return {
|
||||
sx: centerX - sw / 2,
|
||||
sy: centerY - sh / 2,
|
||||
sw,
|
||||
sh,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue