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:
Daniel Luiz Alves
2025-07-11 01:03:45 -03:00
parent f1ef32b5d4
commit a5a22ca5c4
22 changed files with 2425 additions and 2559 deletions

View File

@@ -1721,5 +1721,11 @@
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"imageEdit": {
"title": "تعديل الصورة",
"rotate": "تدوير",
"zoom": "تكبير/تصغير",
"cropInstructions": "اسحب لإعادة تحديد الموضع، غير حجم الزوايا لضبط منطقة القص"
}
}

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -1719,5 +1719,11 @@
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"imageEdit": {
"title": "छवि संपादित करें",
"rotate": "घुमाएं",
"zoom": "ज़ूम",
"cropInstructions": "छवि को पुनः स्थानांतरित करने के लिए खींचें, कोणों को समायोजित करने के लिए आकार बदलें"
}
}

View File

@@ -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"
}
}

View File

@@ -1719,5 +1719,11 @@
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"imageEdit": {
"title": "画像を編集",
"rotate": "回転",
"zoom": "ズーム",
"cropInstructions": "位置を変更するにはドラッグし、カット領域を調整するには角をリサイズしてください"
}
}

View File

@@ -1719,5 +1719,11 @@
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"imageEdit": {
"title": "이미지 편집",
"rotate": "회전",
"zoom": "확대/축소",
"cropInstructions": "위치를 변경하려면 드래그하고, 자르기 영역을 조정하려면 모서리를 확대/축소하세요"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -1719,5 +1719,11 @@
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"imageEdit": {
"title": "Редактировать изображение",
"rotate": "Повернуть",
"zoom": "Увеличить",
"cropInstructions": "Перетащите, чтобы переместить, измените размер углов, чтобы отрегулировать область обрезки"
}
}

View File

@@ -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"
}
}

View File

@@ -1719,5 +1719,11 @@
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"imageEdit": {
"title": "编辑图片",
"rotate": "旋转",
"zoom": "缩放",
"cropInstructions": "拖动以重新定位,调整角落大小以调整裁剪区域"
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,36 +76,41 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
<Card>
<CardHeader className="flex flex-row items-center gap-4">
<div className="relative group">
<Avatar className="w-25 h-25">
<AvatarImage src={userData?.image} />
<AvatarFallback className="absolute inset-0 rounded-full border text-4xl font-bold">
{userData?.firstName
? userData.firstName
.split(" ")
.map((firstName) => firstName[0])
.join("")
.toUpperCase()
: ""}
</AvatarFallback>
</Avatar>
{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">
{userData?.firstName
? userData.firstName
.split(" ")
.map((firstName) => firstName[0])
.join("")
.toUpperCase()
: ""}
</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>
);
}

View File

@@ -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,

View 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>
);
}

View File

@@ -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,

View 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 };