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'; 'use client';
import { useRef, useState } from 'react';
import Image from 'next/image';
import { type ChangeEvent, useRef, useState } from 'react';
import { import {
type Preloaded, type Preloaded,
usePreloadedQuery, usePreloadedQuery,
@@ -7,107 +9,223 @@ import {
useQuery, useQuery,
} from 'convex/react'; } from 'convex/react';
import { api } from '~/convex/_generated/api'; 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 { 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'; import { type Id } from '~/convex/_generated/dataModel';
type AvatarUploadProps = { 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 user = usePreloadedQuery(preloadedUser);
const [isUploading, setIsUploading] = useState(false); 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 inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl); const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUserImage = useMutation(api.auth.updateUserImage); const updateUserImage = useMutation(api.auth.updateUserImage);
const currentImageUrl = useQuery( const currentImageUrl = useQuery(
api.files.getImageUrl, api.files.getImageUrl,
user?.image ? { storageId: user.image } : 'skip', user?.image ? { storageId: user.image } : 'skip',
); );
const handleFileUpload = async ( const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
event: React.ChangeEvent<HTMLInputElement>, const file = event.target.files?.[0] ?? null;
) => {
const file = event.target.files?.[0];
if (!file) return; if (!file) return;
if (!file?.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('Please select an image file.'); 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; return;
} }
setIsUploading(true); setIsUploading(true);
try { try {
const { blob, type } = await dataUrlToBlob(croppedImage);
const postUrl = await generateUploadUrl(); const postUrl = await generateUploadUrl();
const result = await fetch(postUrl, { const result = await fetch(postUrl, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': file.type }, headers: { 'Content-Type': type },
body: file, body: blob,
}); });
if (!result.ok) { if (!result.ok) {
const msg = await result.text().catch(() => 'Upload failed.'); const msg = await result.text().catch(() => 'Upload failed.');
throw new Error(msg); throw new Error(msg);
} }
const uploadResponse = (await result.json()) as { const uploadResponse = (await result.json()) as {
storageId: Id<'_storage'>; storageId: Id<'_storage'>,
}; };
await updateUserImage({ storageId: uploadResponse.storageId }); await updateUserImage({ storageId: uploadResponse.storageId });
toast.success('Profile picture updated.'); toast.success('Profile picture updated.');
handleReset();
} catch (error) { } catch (error) {
console.error('Upload failed:', error); console.error('Upload failed:', error);
toast.error('Upload failed. Please try again.'); toast.error('Upload failed. Please try again.');
} finally { } finally {
setIsUploading(false); setIsUploading(false);
if (inputRef.current) inputRef.current.value = '';
} }
}; };
return ( return (
<CardContent> <CardContent>
<div className='flex flex-col items-center'> <div className='flex flex-col items-center gap-4'>
<div {/* Current avatar + trigger (hidden when cropping) */}
className='relative group cursor-pointer mb-4' {!selectedFile && (
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 <div
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50 className='relative group cursor-pointer'
transition-all flex items-center justify-center' onClick={() => inputRef.current?.click()}
> >
<Upload <BasedAvatar
className='text-white opacity-0 group-hover:opacity-100 src={currentImageUrl ?? undefined}
transition-opacity' fullName={user?.name}
size={24} 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>
<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} ref={inputRef}
id='avatar-upload' id='avatar-upload'
type='file' type='file'
accept='image/*' accept='image/*'
className='hidden' className='hidden'
onChange={handleFileUpload} onChange={handleFileChange}
disabled={isUploading} 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'> <div className='flex items-center text-sm text-gray-500 mt-2'>
<Loader2 className='h-4 w-4 mr-2 animate-spin' /> <Loader2 className='h-4 w-4 mr-2 animate-spin' />
Uploading... Uploading...
@@ -117,5 +235,3 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
</CardContent> </CardContent>
); );
}; };
export { AvatarUpload };

View File

@@ -41,6 +41,18 @@ export {
FormMessage, FormMessage,
FormField, FormField,
} from './form'; } 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 { Input } from './input';
export { Label } from './label'; export { Label } from './label';
export { export {

View File

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