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.
75 lines
2.8 KiB
TypeScript
75 lines
2.8 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Link, useLocation } from 'react-router'
|
|
import { Notice } from '@shared/ui/Notice'
|
|
import { ApiError } from '@shared/api/client'
|
|
import { useSession } from '@features/auth/session'
|
|
import { listSlots } from '@features/configs/api'
|
|
import { copyToSlot } from '../api'
|
|
|
|
const SLOTS = [1, 2, 3, 4] as const
|
|
|
|
export function CopyToSlot({ listingId }: { listingId: string }) {
|
|
const { t } = useTranslation('showcase')
|
|
const { status } = useSession()
|
|
const location = useLocation()
|
|
const client = useQueryClient()
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [copiedSlot, setCopiedSlot] = useState<number | null>(null)
|
|
|
|
const { data: slots } = useQuery({ queryKey: ['configs'], queryFn: listSlots, enabled: status === 'authenticated' })
|
|
const occupied = new Set((slots ?? []).map((s) => s.slot))
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (slot: number) => copyToSlot(listingId, slot),
|
|
onSuccess: (_data, slot) => {
|
|
setError(null)
|
|
setCopiedSlot(slot)
|
|
client.invalidateQueries({ queryKey: ['configs'] })
|
|
},
|
|
onError: (err) => {
|
|
setError(err instanceof ApiError && err.status === 409 ? t('copy.conflict') : t('page.error'))
|
|
},
|
|
})
|
|
|
|
if (status !== 'authenticated') {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-frost">{t('copy.loginPrompt')}</p>
|
|
<Link to="/login" state={{ from: location.pathname }} className="text-ice hover:underline">
|
|
{t('copy.login')}
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<h2 className="font-display text-lg text-snow">{t('copy.heading')}</h2>
|
|
{copiedSlot ? (
|
|
<Notice tone="info">
|
|
{t('copy.success', { slot: copiedSlot })}{' '}
|
|
<Link to="/configs" className="text-ice hover:underline">
|
|
{t('copy.openConfigs')}
|
|
</Link>
|
|
</Notice>
|
|
) : null}
|
|
{error ? <Notice tone="error">{error}</Notice> : null}
|
|
<div role="group" aria-label={t('copy.heading')} className="inline-flex overflow-hidden rounded-ctl border border-shoal">
|
|
{SLOTS.map((slot) => (
|
|
<button
|
|
key={slot}
|
|
type="button"
|
|
disabled={occupied.has(slot) || mutation.isPending}
|
|
title={occupied.has(slot) ? t('copy.occupied') : undefined}
|
|
onClick={() => mutation.mutate(slot)}
|
|
className="border-r border-shoal px-4 py-2 text-sm font-semibold text-snow last:border-r-0 hover:bg-ice/10 disabled:cursor-not-allowed disabled:text-frost/50 disabled:hover:bg-transparent"
|
|
>
|
|
{t('copy.slot', { slot })}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|