Added cropping for pfp
This commit is contained in:
		@@ -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 };
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user