LoVisual/frontend/src/features/showcase/components/CopyToSlot.tsx
loki5512344 fd2e29cede
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.
2026-09-28 22:56:01 +02:00

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