mirror of
				https://github.com/kyantech/Palmr.git
				synced 2025-11-03 21:43:20 +00:00 
			
		
		
		
	feat(profile): implement image editing functionality with cropping and zooming
- Added a new ImageEditModal component for cropping and adjusting images. - Integrated image editing capabilities into the ProfilePicture component, allowing users to edit their profile images. - Updated translations for image editing features in multiple languages. - Introduced a Skeleton component for loading states during image processing. - Enhanced file upload handling with chunked uploads for better performance.
This commit is contained in:
		@@ -1721,5 +1721,11 @@
 | 
			
		||||
    "passwordRequired": "كلمة المرور مطلوبة",
 | 
			
		||||
    "nameRequired": "الاسم مطلوب",
 | 
			
		||||
    "required": "هذا الحقل مطلوب"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "تعديل الصورة",
 | 
			
		||||
    "rotate": "تدوير",
 | 
			
		||||
    "zoom": "تكبير/تصغير",
 | 
			
		||||
    "cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "Passwort ist erforderlich",
 | 
			
		||||
    "nameRequired": "Name ist erforderlich",
 | 
			
		||||
    "required": "Dieses Feld ist erforderlich"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Bild bearbeiten",
 | 
			
		||||
    "rotate": "Drehen",
 | 
			
		||||
    "zoom": "Zoom",
 | 
			
		||||
    "cropInstructions": "Ziehen Sie, um die Position zu ändern, ändern Sie die Größe der Ecken, um die Zuschneidefläche anzupassen"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -400,6 +400,12 @@
 | 
			
		||||
  "navigation": {
 | 
			
		||||
    "dashboard": "Dashboard"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Edit Image",
 | 
			
		||||
    "rotate": "Rotate",
 | 
			
		||||
    "zoom": "Zoom",
 | 
			
		||||
    "cropInstructions": "Drag to reposition, resize corners to adjust crop area"
 | 
			
		||||
  },
 | 
			
		||||
  "profile": {
 | 
			
		||||
    "password": {
 | 
			
		||||
      "title": "Change Password",
 | 
			
		||||
 
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "Se requiere la contraseña",
 | 
			
		||||
    "nameRequired": "El nombre es obligatorio",
 | 
			
		||||
    "required": "Este campo es obligatorio"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Editar Imagen",
 | 
			
		||||
    "rotate": "Rotar",
 | 
			
		||||
    "zoom": "Zoom",
 | 
			
		||||
    "cropInstructions": "Arrastra para reubicar, ajusta las esquinas para ajustar el área de recorte"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "Le mot de passe est requis",
 | 
			
		||||
    "nameRequired": "Nome é obrigatório",
 | 
			
		||||
    "required": "Este campo é obrigatório"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Modifier l'Image",
 | 
			
		||||
    "rotate": "Tourner",
 | 
			
		||||
    "zoom": "Zoom",
 | 
			
		||||
    "cropInstructions": "Glisser pour répositionner, redimensionner les coins pour ajuster la zone de découpe"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "पासवर्ड आवश्यक है",
 | 
			
		||||
    "nameRequired": "नाम आवश्यक है",
 | 
			
		||||
    "required": "यह फ़ील्ड आवश्यक है"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "छवि संपादित करें",
 | 
			
		||||
    "rotate": "घुमाएं",
 | 
			
		||||
    "zoom": "ज़ूम",
 | 
			
		||||
    "cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordMinLength": "La password deve contenere almeno 6 caratteri",
 | 
			
		||||
    "nameRequired": "Il nome è obbligatorio",
 | 
			
		||||
    "required": "Questo campo è obbligatorio"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Modifica Immagine",
 | 
			
		||||
    "rotate": "Ruota",
 | 
			
		||||
    "zoom": "Zoom",
 | 
			
		||||
    "cropInstructions": "Trascina per riposizionare, ridimensiona gli angoli per adattare l'area di ritaglio"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "パスワードは必須です",
 | 
			
		||||
    "nameRequired": "名前は必須です",
 | 
			
		||||
    "required": "このフィールドは必須です"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "画像を編集",
 | 
			
		||||
    "rotate": "回転",
 | 
			
		||||
    "zoom": "ズーム",
 | 
			
		||||
    "cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "비밀번호는 필수입니다",
 | 
			
		||||
    "nameRequired": "이름은 필수입니다",
 | 
			
		||||
    "required": "이 필드는 필수입니다"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "이미지 편집",
 | 
			
		||||
    "rotate": "회전",
 | 
			
		||||
    "zoom": "확대/축소",
 | 
			
		||||
    "cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
 | 
			
		||||
    "nameRequired": "Naam is verplicht",
 | 
			
		||||
    "required": "Dit veld is verplicht"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Afbeelding bewerken",
 | 
			
		||||
    "rotate": "Draai",
 | 
			
		||||
    "zoom": "Vergroot",
 | 
			
		||||
    "cropInstructions": "Sleep om te herpositioneren, verander de grootte van de hoeken om de uitsnijdgebied aan te passen"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
 | 
			
		||||
    "nameRequired": "Nazwa jest wymagana",
 | 
			
		||||
    "required": "To pole jest wymagane"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Edytuj obraz",
 | 
			
		||||
    "rotate": "Obróć",
 | 
			
		||||
    "zoom": "Powiększ",
 | 
			
		||||
    "cropInstructions": "Przeciągnij, aby przesunąć, zmień rozmiar rogów, aby dostosować obszar przycięcia"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "lastNameRequired": "O sobrenome é necessário",
 | 
			
		||||
    "usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
 | 
			
		||||
    "usernameSpaces": "O nome de usuário não pode conter espaços"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Editar imagem",
 | 
			
		||||
    "rotate": "Girar",
 | 
			
		||||
    "zoom": "Ampliar",
 | 
			
		||||
    "cropInstructions": "Arraste para reposicionar, redimensione os cantos para ajustar a área de recorte"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "Требуется пароль",
 | 
			
		||||
    "nameRequired": "Требуется имя",
 | 
			
		||||
    "required": "Это поле обязательно"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Редактировать изображение",
 | 
			
		||||
    "rotate": "Повернуть",
 | 
			
		||||
    "zoom": "Увеличить",
 | 
			
		||||
    "cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "Şifre gerekli",
 | 
			
		||||
    "nameRequired": "İsim gereklidir",
 | 
			
		||||
    "required": "Bu alan zorunludur"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "Resmi Düzenle",
 | 
			
		||||
    "rotate": "Döndür",
 | 
			
		||||
    "zoom": "Yakınlaştır",
 | 
			
		||||
    "cropInstructions": "Yerleştirmek için sürükleyin, kırpma alanını ayarlamak için köşeleri yeniden boyutlandırın"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1719,5 +1719,11 @@
 | 
			
		||||
    "passwordRequired": "密码为必填项",
 | 
			
		||||
    "nameRequired": "名称为必填项",
 | 
			
		||||
    "required": "此字段为必填项"
 | 
			
		||||
  },
 | 
			
		||||
  "imageEdit": {
 | 
			
		||||
    "title": "编辑图片",
 | 
			
		||||
    "rotate": "旋转",
 | 
			
		||||
    "zoom": "缩放",
 | 
			
		||||
    "cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -69,6 +69,7 @@
 | 
			
		||||
    "react-dropzone": "^14.3.8",
 | 
			
		||||
    "react-hook-form": "^7.59.0",
 | 
			
		||||
    "react-icons": "^5.5.0",
 | 
			
		||||
    "react-image-crop": "^11.0.10",
 | 
			
		||||
    "react-qr-reader": "3.0.0-beta-1",
 | 
			
		||||
    "sonner": "^2.0.5",
 | 
			
		||||
    "tailwind-merge": "^3.3.1",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4517
									
								
								apps/web/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4517
									
								
								apps/web/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,9 +1,10 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { useRef } from "react";
 | 
			
		||||
import { useRef, useState } from "react";
 | 
			
		||||
import { IconCamera, IconTrash } from "@tabler/icons-react";
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
 | 
			
		||||
import { ImageEditModal } from "@/components/modals/image-edit-modal";
 | 
			
		||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Card, CardHeader } from "@/components/ui/card";
 | 
			
		||||
@@ -14,11 +15,15 @@ import {
 | 
			
		||||
  DropdownMenuTrigger,
 | 
			
		||||
} from "@/components/ui/dropdown-menu";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Skeleton } from "@/components/ui/skeleton";
 | 
			
		||||
import type { ProfilePictureProps } from "../types";
 | 
			
		||||
 | 
			
		||||
export function ProfilePicture({ userData, onImageChange, onImageRemove }: ProfilePictureProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
  const fileInputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
  const [isEditModalOpen, setIsEditModalOpen] = useState(false);
 | 
			
		||||
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const handleAvatarClick = () => {
 | 
			
		||||
    fileInputRef.current?.click();
 | 
			
		||||
@@ -28,7 +33,42 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
 | 
			
		||||
    const file = e.target.files?.[0];
 | 
			
		||||
 | 
			
		||||
    if (file) {
 | 
			
		||||
      onImageChange(file);
 | 
			
		||||
      setSelectedFile(file);
 | 
			
		||||
      setIsEditModalOpen(true);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleImageEdit = async (croppedImageFile: File) => {
 | 
			
		||||
    setIsLoading(true);
 | 
			
		||||
    setIsEditModalOpen(false);
 | 
			
		||||
    setSelectedFile(null);
 | 
			
		||||
    // Reset the file input
 | 
			
		||||
    if (fileInputRef.current) {
 | 
			
		||||
      fileInputRef.current.value = "";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await onImageChange(croppedImageFile);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsLoading(false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleEditModalClose = () => {
 | 
			
		||||
    setIsEditModalOpen(false);
 | 
			
		||||
    setSelectedFile(null);
 | 
			
		||||
    // Reset the file input
 | 
			
		||||
    if (fileInputRef.current) {
 | 
			
		||||
      fileInputRef.current.value = "";
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleImageRemove = async () => {
 | 
			
		||||
    setIsLoading(true);
 | 
			
		||||
    try {
 | 
			
		||||
      await onImageRemove();
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsLoading(false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -36,6 +76,9 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
 | 
			
		||||
    <Card>
 | 
			
		||||
      <CardHeader className="flex flex-row items-center gap-4">
 | 
			
		||||
        <div className="relative group">
 | 
			
		||||
          {isLoading ? (
 | 
			
		||||
            <Skeleton className="w-25 h-25 rounded-full" />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <Avatar className="w-25 h-25">
 | 
			
		||||
              <AvatarImage src={userData?.image} />
 | 
			
		||||
              <AvatarFallback className="absolute inset-0 rounded-full border text-4xl font-bold">
 | 
			
		||||
@@ -48,24 +91,26 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
 | 
			
		||||
                  : ""}
 | 
			
		||||
              </AvatarFallback>
 | 
			
		||||
            </Avatar>
 | 
			
		||||
          )}
 | 
			
		||||
          <DropdownMenu>
 | 
			
		||||
            <DropdownMenuTrigger asChild>
 | 
			
		||||
              <Button
 | 
			
		||||
                size="icon"
 | 
			
		||||
                className="absolute bottom-0 right-0 bg-primary text-primary-foreground rounded-full cursor-pointer"
 | 
			
		||||
                variant="default"
 | 
			
		||||
                disabled={isLoading}
 | 
			
		||||
              >
 | 
			
		||||
                <IconCamera className="h-4 w-4" />
 | 
			
		||||
              </Button>
 | 
			
		||||
            </DropdownMenuTrigger>
 | 
			
		||||
            <DropdownMenuContent>
 | 
			
		||||
              {!!userData?.image ? (
 | 
			
		||||
                <DropdownMenuItem className="text-destructive" onClick={onImageRemove}>
 | 
			
		||||
                <DropdownMenuItem className="text-destructive" onClick={handleImageRemove} disabled={isLoading}>
 | 
			
		||||
                  <IconTrash className="h-4 w-4" />
 | 
			
		||||
                  {t("profile.picture.removePhoto")}
 | 
			
		||||
                </DropdownMenuItem>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <DropdownMenuItem onClick={handleAvatarClick}>
 | 
			
		||||
                <DropdownMenuItem onClick={handleAvatarClick} disabled={isLoading}>
 | 
			
		||||
                  <IconCamera className="h-4 w-4" />
 | 
			
		||||
                  {t("profile.picture.uploadPhoto")}
 | 
			
		||||
                </DropdownMenuItem>
 | 
			
		||||
@@ -79,6 +124,13 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
 | 
			
		||||
          <p className="text-sm text-muted-foreground">{t("profile.picture.description")}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </CardHeader>
 | 
			
		||||
 | 
			
		||||
      <ImageEditModal
 | 
			
		||||
        isOpen={isEditModalOpen}
 | 
			
		||||
        onClose={handleEditModalClose}
 | 
			
		||||
        onSave={handleImageEdit}
 | 
			
		||||
        imageFile={selectedFile}
 | 
			
		||||
      />
 | 
			
		||||
    </Card>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -125,9 +125,10 @@ export function GlobalDropZone({ onSuccess, children }: GlobalDropZoneProps) {
 | 
			
		||||
        setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
 | 
			
		||||
 | 
			
		||||
        const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
 | 
			
		||||
        const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
 | 
			
		||||
 | 
			
		||||
        if (shouldUseChunked) {
 | 
			
		||||
          const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
 | 
			
		||||
 | 
			
		||||
          const result = await ChunkedUploader.uploadFile({
 | 
			
		||||
            file,
 | 
			
		||||
            url,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										279
									
								
								apps/web/src/components/modals/image-edit-modal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										279
									
								
								apps/web/src/components/modals/image-edit-modal.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,279 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { useCallback, useEffect, useRef, useState } from "react";
 | 
			
		||||
import { IconCheck, IconRotateClockwise, IconX, IconZoomIn, IconZoomOut } from "@tabler/icons-react";
 | 
			
		||||
import { useTranslations } from "next-intl";
 | 
			
		||||
import ReactCrop, { centerCrop, Crop, makeAspectCrop, PixelCrop } from "react-image-crop";
 | 
			
		||||
 | 
			
		||||
import "react-image-crop/dist/ReactCrop.css";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
 | 
			
		||||
import { Skeleton } from "@/components/ui/skeleton";
 | 
			
		||||
import { Slider } from "@/components/ui/slider";
 | 
			
		||||
 | 
			
		||||
interface ImageEditModalProps {
 | 
			
		||||
  isOpen: boolean;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  onSave: (croppedImageFile: File) => void;
 | 
			
		||||
  imageFile: File | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function centerAspectCrop(mediaWidth: number, mediaHeight: number, aspect: number) {
 | 
			
		||||
  return centerCrop(
 | 
			
		||||
    makeAspectCrop(
 | 
			
		||||
      {
 | 
			
		||||
        unit: "%",
 | 
			
		||||
        width: 90,
 | 
			
		||||
      },
 | 
			
		||||
      aspect,
 | 
			
		||||
      mediaWidth,
 | 
			
		||||
      mediaHeight
 | 
			
		||||
    ),
 | 
			
		||||
    mediaWidth,
 | 
			
		||||
    mediaHeight
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ImageEditModal({ isOpen, onClose, onSave, imageFile }: ImageEditModalProps) {
 | 
			
		||||
  const t = useTranslations();
 | 
			
		||||
  const [crop, setCrop] = useState<Crop>();
 | 
			
		||||
  const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
 | 
			
		||||
  const [scale, setScale] = useState(1);
 | 
			
		||||
  const [rotate, setRotate] = useState(0);
 | 
			
		||||
  const aspect = 1;
 | 
			
		||||
  const [imageSrc, setImageSrc] = useState<string>("");
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
  const [isImageLoading, setIsImageLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const imgRef = useRef<HTMLImageElement>(null);
 | 
			
		||||
  const previewCanvasRef = useRef<HTMLCanvasElement>(null);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (imageFile) {
 | 
			
		||||
      setIsImageLoading(true);
 | 
			
		||||
      const reader = new FileReader();
 | 
			
		||||
      reader.onload = (e) => {
 | 
			
		||||
        setImageSrc(e.target?.result as string);
 | 
			
		||||
        setIsImageLoading(false);
 | 
			
		||||
      };
 | 
			
		||||
      reader.onerror = () => {
 | 
			
		||||
        setIsImageLoading(false);
 | 
			
		||||
      };
 | 
			
		||||
      reader.readAsDataURL(imageFile);
 | 
			
		||||
    }
 | 
			
		||||
  }, [imageFile]);
 | 
			
		||||
 | 
			
		||||
  const onImageLoad = useCallback(
 | 
			
		||||
    (e: React.SyntheticEvent<HTMLImageElement>) => {
 | 
			
		||||
      if (aspect) {
 | 
			
		||||
        const { width, height } = e.currentTarget;
 | 
			
		||||
        setCrop(centerAspectCrop(width, height, aspect));
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [aspect]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRotate = () => {
 | 
			
		||||
    setRotate((prev) => (prev + 90) % 360);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleZoomIn = () => {
 | 
			
		||||
    setScale((prev) => Math.min(prev + 0.1, 3));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleZoomOut = () => {
 | 
			
		||||
    setScale((prev) => Math.max(prev - 0.1, 0.5));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleScaleChange = (value: number[]) => {
 | 
			
		||||
    setScale(value[0]);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getCroppedImage = useCallback(async (): Promise<File | null> => {
 | 
			
		||||
    if (!completedCrop || !imgRef.current || !previewCanvasRef.current) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const image = imgRef.current;
 | 
			
		||||
    const canvas = previewCanvasRef.current;
 | 
			
		||||
    const crop = completedCrop;
 | 
			
		||||
 | 
			
		||||
    const scaleX = image.naturalWidth / image.width;
 | 
			
		||||
    const scaleY = image.naturalHeight / image.height;
 | 
			
		||||
    const ctx = canvas.getContext("2d");
 | 
			
		||||
 | 
			
		||||
    if (!ctx) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const pixelRatio = window.devicePixelRatio;
 | 
			
		||||
    canvas.width = crop.width * pixelRatio * scaleX;
 | 
			
		||||
    canvas.height = crop.height * pixelRatio * scaleY;
 | 
			
		||||
 | 
			
		||||
    ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
 | 
			
		||||
    ctx.imageSmoothingQuality = "high";
 | 
			
		||||
 | 
			
		||||
    const cropX = crop.x * scaleX;
 | 
			
		||||
    const cropY = crop.y * scaleY;
 | 
			
		||||
 | 
			
		||||
    const rotateRads = rotate * (Math.PI / 180);
 | 
			
		||||
    const centerX = image.naturalWidth / 2;
 | 
			
		||||
    const centerY = image.naturalHeight / 2;
 | 
			
		||||
 | 
			
		||||
    ctx.save();
 | 
			
		||||
 | 
			
		||||
    ctx.translate(-cropX, -cropY);
 | 
			
		||||
    ctx.translate(centerX, centerY);
 | 
			
		||||
    ctx.rotate(rotateRads);
 | 
			
		||||
    ctx.scale(scale, scale);
 | 
			
		||||
    ctx.translate(-centerX, -centerY);
 | 
			
		||||
 | 
			
		||||
    ctx.drawImage(image, 0, 0);
 | 
			
		||||
 | 
			
		||||
    ctx.restore();
 | 
			
		||||
 | 
			
		||||
    return new Promise<File>((resolve) => {
 | 
			
		||||
      canvas.toBlob(
 | 
			
		||||
        (blob) => {
 | 
			
		||||
          if (blob) {
 | 
			
		||||
            const file = new File([blob], "cropped-image.png", { type: "image/png" });
 | 
			
		||||
            resolve(file);
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "image/png",
 | 
			
		||||
        1
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
  }, [completedCrop, scale, rotate]);
 | 
			
		||||
 | 
			
		||||
  const handleSave = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      setIsLoading(true);
 | 
			
		||||
      const croppedImageFile = await getCroppedImage();
 | 
			
		||||
      if (croppedImageFile) {
 | 
			
		||||
        onSave(croppedImageFile);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("Error cropping image:", error);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setIsLoading(false);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleClose = () => {
 | 
			
		||||
    setImageSrc("");
 | 
			
		||||
    setCrop(undefined);
 | 
			
		||||
    setCompletedCrop(undefined);
 | 
			
		||||
    setScale(1);
 | 
			
		||||
    setRotate(0);
 | 
			
		||||
    setIsImageLoading(false);
 | 
			
		||||
    onClose();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={handleClose}>
 | 
			
		||||
      <DialogContent className="sm:max-w-2xl h-fit overflow-hidden flex flex-col">
 | 
			
		||||
        <DialogHeader>
 | 
			
		||||
          <DialogTitle>{t("imageEdit.title")}</DialogTitle>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
 | 
			
		||||
        <div className="flex-1 overflow-auto">
 | 
			
		||||
          {isImageLoading ? (
 | 
			
		||||
            <div className="space-y-4">
 | 
			
		||||
              <div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
 | 
			
		||||
                <div className="flex items-center gap-2">
 | 
			
		||||
                  <Skeleton className="h-8 w-20" />
 | 
			
		||||
                  <Skeleton className="h-8 w-10" />
 | 
			
		||||
                  <Skeleton className="h-8 w-10" />
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="flex-1 max-w-xs">
 | 
			
		||||
                  <Skeleton className="h-4 w-16 mb-2" />
 | 
			
		||||
                  <Skeleton className="h-2 w-full" />
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="flex justify-center">
 | 
			
		||||
                <Skeleton className="w-96 h-96" />
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          ) : imageSrc ? (
 | 
			
		||||
            <div className="space-y-4">
 | 
			
		||||
              <div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
 | 
			
		||||
                <div className="flex items-center gap-2">
 | 
			
		||||
                  <Button variant="outline" size="sm" onClick={handleRotate} disabled={isLoading}>
 | 
			
		||||
                    <IconRotateClockwise className="h-4 w-4" />
 | 
			
		||||
                    {t("imageEdit.rotate")}
 | 
			
		||||
                  </Button>
 | 
			
		||||
                  <Button variant="outline" size="sm" onClick={handleZoomOut} disabled={isLoading || scale <= 0.5}>
 | 
			
		||||
                    <IconZoomOut className="h-4 w-4" />
 | 
			
		||||
                  </Button>
 | 
			
		||||
                  <Button variant="outline" size="sm" onClick={handleZoomIn} disabled={isLoading || scale >= 3}>
 | 
			
		||||
                    <IconZoomIn className="h-4 w-4" />
 | 
			
		||||
                  </Button>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="flex-1 max-w-xs">
 | 
			
		||||
                  <label className="text-sm font-medium mb-2 block">
 | 
			
		||||
                    {t("imageEdit.zoom")}: {Math.round(scale * 100)}%
 | 
			
		||||
                  </label>
 | 
			
		||||
                  <Slider
 | 
			
		||||
                    value={[scale]}
 | 
			
		||||
                    onValueChange={handleScaleChange}
 | 
			
		||||
                    max={3}
 | 
			
		||||
                    min={0.5}
 | 
			
		||||
                    step={0.1}
 | 
			
		||||
                    className="w-full"
 | 
			
		||||
                    disabled={isLoading}
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div className="flex justify-center">
 | 
			
		||||
                <ReactCrop
 | 
			
		||||
                  crop={crop}
 | 
			
		||||
                  onChange={(c) => setCrop(c)}
 | 
			
		||||
                  onComplete={(c) => setCompletedCrop(c)}
 | 
			
		||||
                  aspect={aspect}
 | 
			
		||||
                  minWidth={100}
 | 
			
		||||
                  minHeight={100}
 | 
			
		||||
                  keepSelection
 | 
			
		||||
                  className="max-w-full max-h-70%"
 | 
			
		||||
                >
 | 
			
		||||
                  <img
 | 
			
		||||
                    ref={imgRef}
 | 
			
		||||
                    alt="Crop me"
 | 
			
		||||
                    src={imageSrc}
 | 
			
		||||
                    style={{
 | 
			
		||||
                      transform: `scale(${scale}) rotate(${rotate}deg)`,
 | 
			
		||||
                      maxWidth: "100%",
 | 
			
		||||
                      maxHeight: "400px",
 | 
			
		||||
                    }}
 | 
			
		||||
                    onLoad={onImageLoad}
 | 
			
		||||
                  />
 | 
			
		||||
                </ReactCrop>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          ) : null}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <DialogFooter className="flex gap-2">
 | 
			
		||||
          <Button variant="outline" onClick={handleClose} disabled={isLoading}>
 | 
			
		||||
            <IconX className="h-4 w-4" />
 | 
			
		||||
            {t("common.cancel")}
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button onClick={handleSave} disabled={isLoading || !completedCrop}>
 | 
			
		||||
            <IconCheck className="h-4 w-4" />
 | 
			
		||||
            {isLoading ? t("common.saving") : t("common.save")}
 | 
			
		||||
          </Button>
 | 
			
		||||
        </DialogFooter>
 | 
			
		||||
 | 
			
		||||
        {/* Hidden canvas for generating cropped image */}
 | 
			
		||||
        <canvas
 | 
			
		||||
          ref={previewCanvasRef}
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "none",
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -253,9 +253,10 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
 | 
			
		||||
      setFileUploads((prev) => prev.map((u) => (u.id === id ? { ...u, abortController } : u)));
 | 
			
		||||
 | 
			
		||||
      const shouldUseChunked = ChunkedUploader.shouldUseChunkedUpload(file.size);
 | 
			
		||||
      const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
 | 
			
		||||
 | 
			
		||||
      if (shouldUseChunked) {
 | 
			
		||||
        const chunkSize = ChunkedUploader.calculateOptimalChunkSize(file.size);
 | 
			
		||||
 | 
			
		||||
        const result = await ChunkedUploader.uploadFile({
 | 
			
		||||
          file,
 | 
			
		||||
          url,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										7
									
								
								apps/web/src/components/ui/skeleton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								apps/web/src/components/ui/skeleton.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
 | 
			
		||||
  return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Skeleton };
 | 
			
		||||
		Reference in New Issue
	
	Block a user