168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
'use client';
|
||
|
||
import { useRef, useState } from 'react';
|
||
import { Camera, Loader2 } from 'lucide-react';
|
||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||
import { useToast } from '@/components/id/toast-provider';
|
||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
|
||
import { dispatchAvatarUpdated } from '@/lib/avatar-events';
|
||
import { VerificationBadge } from '@/components/id/verification-badge';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
interface PresignedUploadResponse {
|
||
uploadUrl: string;
|
||
storageKey: string;
|
||
expiresAt: string;
|
||
uploadToken?: string;
|
||
}
|
||
|
||
export function AvatarUpload({
|
||
userId,
|
||
displayName,
|
||
hasAvatar,
|
||
token,
|
||
onUpdated,
|
||
isVerified,
|
||
verificationIcon,
|
||
className = 'h-24 w-24',
|
||
badgeSize = 'md'
|
||
}: {
|
||
userId: string;
|
||
displayName?: string;
|
||
hasAvatar?: boolean;
|
||
token: string | null;
|
||
onUpdated: () => Promise<void>;
|
||
isVerified?: boolean;
|
||
verificationIcon?: string | null;
|
||
className?: string;
|
||
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
|
||
}) {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const { showToast } = useToast();
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const { avatarUrl, refreshAvatarUrl } = useAvatarUrl(userId, hasAvatar, token);
|
||
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
|
||
|
||
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
if (!file || !token) return;
|
||
|
||
if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type)) {
|
||
showToast('Допустимы только JPEG, PNG, WEBP или GIF');
|
||
return;
|
||
}
|
||
|
||
setIsUploading(true);
|
||
try {
|
||
const presigned = await apiFetch<PresignedUploadResponse>(
|
||
'/media/avatars/upload-url',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({ contentType: file.type })
|
||
},
|
||
token
|
||
);
|
||
|
||
await uploadMediaObject(presigned, file, file.type);
|
||
|
||
await apiFetch('/media/avatars/confirm', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ storageKey: presigned.storageKey })
|
||
}, token);
|
||
|
||
await onUpdated();
|
||
await refreshAvatarUrl();
|
||
dispatchAvatarUpdated(userId);
|
||
showToast('Аватар обновлён');
|
||
} catch (error) {
|
||
const message = getApiErrorMessage(error, 'Не удалось загрузить аватар');
|
||
if (message) showToast(message);
|
||
} finally {
|
||
setIsUploading(false);
|
||
if (inputRef.current) inputRef.current.value = '';
|
||
}
|
||
}
|
||
|
||
function openFilePicker() {
|
||
if (isUploading || !token) return;
|
||
inputRef.current?.click();
|
||
}
|
||
|
||
return (
|
||
<div className="relative inline-flex shrink-0">
|
||
<button
|
||
type="button"
|
||
className={cn('group relative rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3390ec] focus-visible:ring-offset-2', className)}
|
||
disabled={isUploading || !token}
|
||
aria-label="Изменить фото профиля"
|
||
onClick={openFilePicker}
|
||
>
|
||
<Avatar className={cn('border-4 border-white shadow-xl', className)}>
|
||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
|
||
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)] text-2xl">{initials}</AvatarFallback>
|
||
</Avatar>
|
||
<span
|
||
className={cn(
|
||
'absolute inset-0 flex items-center justify-center rounded-full transition',
|
||
isUploading ? 'bg-black/45 opacity-100' : 'bg-black/0 opacity-0 group-hover:bg-black/40 group-hover:opacity-100 group-focus-visible:bg-black/40 group-focus-visible:opacity-100'
|
||
)}
|
||
>
|
||
{isUploading ? (
|
||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||
) : (
|
||
<Camera className="h-5 w-5 text-white" />
|
||
)}
|
||
</span>
|
||
</button>
|
||
{isVerified ? (
|
||
<VerificationBadge
|
||
verificationIcon={verificationIcon}
|
||
size={badgeSize}
|
||
className="pointer-events-none absolute top-0 right-0 z-20"
|
||
/>
|
||
) : null}
|
||
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="hidden" onChange={handleFileChange} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AvatarDisplay({
|
||
userId,
|
||
displayName,
|
||
hasAvatar,
|
||
token,
|
||
className,
|
||
isVerified,
|
||
verificationIcon,
|
||
badgeSize = 'sm'
|
||
}: {
|
||
userId: string;
|
||
displayName?: string;
|
||
hasAvatar?: boolean;
|
||
token: string | null;
|
||
className?: string;
|
||
isVerified?: boolean;
|
||
verificationIcon?: string | null;
|
||
badgeSize?: 'xs' | 'sm' | 'md' | 'lg';
|
||
}) {
|
||
const { avatarUrl } = useAvatarUrl(userId, hasAvatar, token);
|
||
const initials = (displayName ?? '').slice(0, 2).toUpperCase() || 'ID';
|
||
|
||
return (
|
||
<div className="relative inline-flex shrink-0">
|
||
<Avatar className={className}>
|
||
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName ?? 'Аватар'} /> : null}
|
||
<AvatarFallback className="bg-[linear-gradient(135deg,#6f58ff,#ffd2a8)]">{initials}</AvatarFallback>
|
||
</Avatar>
|
||
{isVerified ? (
|
||
<VerificationBadge
|
||
verificationIcon={verificationIcon}
|
||
size={badgeSize}
|
||
className="absolute -top-0.5 -right-0.5 z-20"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|