237 lines
6.9 KiB
TypeScript
237 lines
6.9 KiB
TypeScript
'use client';
|
|
|
|
import Image from 'next/image';
|
|
import { type ChangeEvent, useRef, useState } from 'react';
|
|
import {
|
|
type Preloaded,
|
|
usePreloadedQuery,
|
|
useMutation,
|
|
useQuery,
|
|
} from 'convex/react';
|
|
import { api } from '~/convex/_generated/api';
|
|
import {
|
|
Avatar,
|
|
AvatarImage,
|
|
BasedAvatar,
|
|
Button,
|
|
CardContent,
|
|
ImageCrop,
|
|
ImageCropApply,
|
|
ImageCropContent,
|
|
Input,
|
|
} from '@/components/ui';
|
|
import { toast } from 'sonner';
|
|
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
|
|
import { type Id } from '~/convex/_generated/dataModel';
|
|
|
|
type AvatarUploadProps = {
|
|
preloadedUser: Preloaded<typeof api.auth.getUser>;
|
|
};
|
|
|
|
const dataUrlToBlob = async (
|
|
dataUrl: string,
|
|
): Promise<{ blob: Blob; type: string }> => {
|
|
const re = /^data:([^;,]+)[;,]/;
|
|
const m = re.exec(dataUrl);
|
|
const type = m?.[1] ?? 'image/png';
|
|
|
|
const res = await fetch(dataUrl);
|
|
const blob = await res.blob();
|
|
return { blob, type };
|
|
};
|
|
|
|
export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
|
const user = usePreloadedQuery(preloadedUser);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [croppedImage, setCroppedImage] = useState<string | null>(null);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
|
const updateUserImage = useMutation(api.auth.updateUserImage);
|
|
|
|
const currentImageUrl = useQuery(
|
|
api.files.getImageUrl,
|
|
user?.image ? { storageId: user.image } : 'skip',
|
|
);
|
|
|
|
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0] ?? null;
|
|
if (!file) return;
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('Please select an image file.');
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
return;
|
|
}
|
|
setSelectedFile(file);
|
|
setCroppedImage(null);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setSelectedFile(null);
|
|
setCroppedImage(null);
|
|
if (inputRef.current) inputRef.current.value = '';
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!croppedImage) {
|
|
toast.error('Please apply a crop first.');
|
|
return;
|
|
}
|
|
setIsUploading(true);
|
|
try {
|
|
const { blob, type } = await dataUrlToBlob(croppedImage);
|
|
const postUrl = await generateUploadUrl();
|
|
|
|
const result = await fetch(postUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': type },
|
|
body: blob,
|
|
});
|
|
if (!result.ok) {
|
|
const msg = await result.text().catch(() => 'Upload failed.');
|
|
throw new Error(msg);
|
|
}
|
|
|
|
const uploadResponse = (await result.json()) as {
|
|
storageId: Id<'_storage'>;
|
|
};
|
|
|
|
await updateUserImage({ storageId: uploadResponse.storageId });
|
|
|
|
toast.success('Profile picture updated.');
|
|
handleReset();
|
|
} catch (error) {
|
|
console.error('Upload failed:', error);
|
|
toast.error('Upload failed. Please try again.');
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<CardContent>
|
|
<div className='flex flex-col items-center gap-4'>
|
|
{/* Current avatar + trigger (hidden when cropping) */}
|
|
{!selectedFile && (
|
|
<div
|
|
className='relative group cursor-pointer'
|
|
onClick={() => inputRef.current?.click()}
|
|
>
|
|
<BasedAvatar
|
|
src={currentImageUrl ?? undefined}
|
|
fullName={user?.name}
|
|
className='h-42 w-42'
|
|
fallbackProps={{ className: 'text-4xl font-semibold' }}
|
|
userIconProps={{ size: 100 }}
|
|
/>
|
|
<div
|
|
className='absolute inset-0 rounded-full bg-black/0
|
|
group-hover:bg-black/50 transition-all flex items-center
|
|
justify-center'
|
|
>
|
|
<Upload
|
|
className='text-white opacity-0 group-hover:opacity-100
|
|
transition-opacity'
|
|
size={24}
|
|
/>
|
|
</div>
|
|
<div
|
|
className='absolute inset-1 transition-all flex items-end
|
|
justify-end'
|
|
>
|
|
<Pencil
|
|
className='text-white opacity-100 group-hover:opacity-0
|
|
transition-opacity'
|
|
size={24}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* File input (hidden) */}
|
|
<Input
|
|
ref={inputRef}
|
|
id='avatar-upload'
|
|
type='file'
|
|
accept='image/*'
|
|
className='hidden'
|
|
onChange={handleFileChange}
|
|
disabled={isUploading}
|
|
/>
|
|
|
|
{/* Crop UI */}
|
|
{selectedFile && !croppedImage && (
|
|
<div className='flex flex-col items-center gap-3'>
|
|
<ImageCrop
|
|
aspect={1}
|
|
circularCrop
|
|
file={selectedFile}
|
|
maxImageSize={3 * 1024 * 1024} // 3MB guard
|
|
onCrop={setCroppedImage}
|
|
>
|
|
<ImageCropContent className='max-w-sm' />
|
|
<div className='flex items-center gap-2'>
|
|
<ImageCropApply />
|
|
<Button
|
|
onClick={handleReset}
|
|
size='icon'
|
|
type='button'
|
|
variant='ghost'
|
|
>
|
|
<XIcon className='size-4' />
|
|
</Button>
|
|
</div>
|
|
</ImageCrop>
|
|
</div>
|
|
)}
|
|
|
|
{/* Cropped preview + actions */}
|
|
{croppedImage && (
|
|
<div className='flex flex-col items-center gap-3'>
|
|
<Avatar className='h-42 w-42'>
|
|
<AvatarImage alt='Cropped preview' src={croppedImage} />
|
|
</Avatar>
|
|
<div className='flex items-center gap-1'>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={isUploading}
|
|
className='px-4'
|
|
>
|
|
{isUploading ? (
|
|
<span className='inline-flex items-center gap-2'>
|
|
<Loader2 className='h-4 w-4 animate-spin' />
|
|
Saving...
|
|
</span>
|
|
) : (
|
|
'Save Avatar'
|
|
)}
|
|
</Button>
|
|
<Button
|
|
onClick={handleReset}
|
|
size='icon'
|
|
type='button'
|
|
className='dark:bg-red-500/30 bg-red-400/80
|
|
hover:dark:text-red-300/60 hover:text-red-800/80
|
|
hover:dark:bg-accent'
|
|
variant='secondary'
|
|
>
|
|
<XIcon className='size-4' />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Uploading indicator */}
|
|
{isUploading && !croppedImage && (
|
|
<div className='flex items-center text-sm text-gray-500 mt-2'>
|
|
<Loader2 className='h-4 w-4 mr-2 animate-spin' />
|
|
Uploading...
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
);
|
|
};
|