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,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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue