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

@ -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>
)
}