From 37c3767c71a2fc9c62e2015a269085038cae2f60 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Mon, 8 Sep 2025 16:21:36 -0500 Subject: [PATCH] Added cropping for pfp --- .../layout/profile/avatar-upload.tsx | 208 ++++++++++++++---- src/components/ui/index.tsx | 12 + .../ui/shadcn-io/image-crop/index.tsx | 2 +- 3 files changed, 175 insertions(+), 47 deletions(-) diff --git a/src/components/layout/profile/avatar-upload.tsx b/src/components/layout/profile/avatar-upload.tsx index 7e61dd8..96b3d3f 100644 --- a/src/components/layout/profile/avatar-upload.tsx +++ b/src/components/layout/profile/avatar-upload.tsx @@ -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; + preloadedUser: Preloaded, }; -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(null); + const [croppedImage, setCroppedImage] = useState(null); + const inputRef = useRef(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, - ) => { - const file = event.target.files?.[0]; + const handleFileChange = (event: ChangeEvent) => { + 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 ( -
-
document.getElementById('avatar-upload')?.click()} - > - +
+ {/* Current avatar + trigger (hidden when cropping) */} + {!selectedFile && (
inputRef.current?.click()} > - +
+ +
+
+ +
-
- -
-
+ )} - - {isUploading && ( + {/* Crop UI */} + {selectedFile && !croppedImage && ( +
+ + +
+ + + +
+
+
+ )} + + {/* Cropped preview + actions */} + {croppedImage && ( +
+ Cropped preview +
+ + +
+
+ )} + + {/* Uploading indicator */} + {isUploading && !croppedImage && (
Uploading... @@ -117,5 +235,3 @@ const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { ); }; - -export { AvatarUpload }; diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index 1236b1c..3400d35 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -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 { diff --git a/src/components/ui/shadcn-io/image-crop/index.tsx b/src/components/ui/shadcn-io/image-crop/index.tsx index f68ab14..ca7cce4 100644 --- a/src/components/ui/shadcn-io/image-crop/index.tsx +++ b/src/components/ui/shadcn-io/image-crop/index.tsx @@ -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 {