Added cropping for pfp

This commit is contained in:
2025-09-08 16:21:36 -05:00
parent 3eff470a80
commit 37c3767c71
3 changed files with 175 additions and 47 deletions

View File

@@ -1,5 +1,7 @@
'use client';
import { useRef, useState } from 'react';
import Image from 'next/image';
import { type ChangeEvent, useRef, useState } from 'react';
import {
type Preloaded,
usePreloadedQuery,
@@ -7,107 +9,223 @@ import {
useQuery,
} from 'convex/react';
import { api } from '~/convex/_generated/api';
import { BasedAvatar, CardContent } from '@/components/ui';
import {
BasedAvatar,
Button,
CardContent,
ImageCrop,
ImageCropApply,
ImageCropContent,
ImageCropReset,
Input,
} from '@/components/ui';
import { toast } from 'sonner';
import { Loader2, Pencil, Upload } from 'lucide-react';
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
import { type Id } from '~/convex/_generated/dataModel';
type AvatarUploadProps = {
preloadedUser: Preloaded<typeof api.auth.getUser>;
preloadedUser: Preloaded<typeof api.auth.getUser>,
};
const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
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 handleFileUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
if (!file?.type.startsWith('image/')) {
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': file.type },
body: file,
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'>;
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);
if (inputRef.current) inputRef.current.value = '';
}
};
return (
<CardContent>
<div className='flex flex-col items-center'>
<div
className='relative group cursor-pointer mb-4'
onClick={() => document.getElementById('avatar-upload')?.click()}
>
<BasedAvatar
src={currentImageUrl ?? undefined}
fullName={user?.name}
className='h-32 w-32'
fallbackProps={{ className: 'text-4xl font-semibold' }}
userIconProps={{ size: 100 }}
/>
<div className='flex flex-col items-center gap-4'>
{/* Current avatar + trigger (hidden when cropping) */}
{!selectedFile && (
<div
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
transition-all flex items-center justify-center'
className='relative group cursor-pointer'
onClick={() => inputRef.current?.click()}
>
<Upload
className='text-white opacity-0 group-hover:opacity-100
transition-opacity'
size={24}
<BasedAvatar
src={currentImageUrl ?? undefined}
fullName={user?.name}
className='h-32 w-32'
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>
<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>
)}
<input
{/* File input (hidden) */}
<Input
ref={inputRef}
id='avatar-upload'
type='file'
accept='image/*'
className='hidden'
onChange={handleFileUpload}
onChange={handleFileChange}
disabled={isUploading}
/>
{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 />
<ImageCropReset />
<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'>
<Image
alt='Cropped preview'
className='overflow-hidden rounded-full'
height={128}
src={croppedImage}
unoptimized
width={128}
/>
<div className='flex items-center gap-2'>
<Button
onClick={handleSave}
disabled={isUploading}
className='px-6'
>
{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'
variant='ghost'
>
<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...
@@ -117,5 +235,3 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
</CardContent>
);
};
export { AvatarUpload };

View File

@@ -41,6 +41,18 @@ export {
FormMessage,
FormField,
} from './form';
export {
type ImageCropProps,
type ImageCropApplyProps,
type ImageCropContentProps,
type ImageCropResetProps,
type CropperProps,
Cropper,
ImageCrop,
ImageCropApply,
ImageCropContent,
ImageCropReset,
} from './shadcn-io/image-crop';
export { Input } from './input';
export { Label } from './label';
export {

View File

@@ -1,6 +1,6 @@
'use client';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import { Button } from '@/components/ui';
import { CropIcon, RotateCcwIcon } from 'lucide-react';
import { Slot } from 'radix-ui';
import {