From fe2a5ac4005c93845fc8238d8edc1bcfa1fdd771 Mon Sep 17 00:00:00 2001 From: sajjad isvand Date: Tue, 21 Jul 2026 11:34:02 +0330 Subject: [PATCH] feat(profile): add image cropping functionality for avatar upload --- bun.lock | 8 ++ package.json | 1 + .../modals/avatar/avatar-crop.modal.tsx | 126 ++++++++++++++++++ .../components/modals/avatar/edit.avatar.tsx | 126 ++++++++++++++++++ .../account/components/modals/edit.avatar.tsx | 90 ------------- .../account/components/profile-display.tsx | 2 +- 6 files changed, 262 insertions(+), 91 deletions(-) create mode 100644 src/layouts/setting/tabs/account/components/modals/avatar/avatar-crop.modal.tsx create mode 100644 src/layouts/setting/tabs/account/components/modals/avatar/edit.avatar.tsx delete mode 100644 src/layouts/setting/tabs/account/components/modals/edit.avatar.tsx diff --git a/bun.lock b/bun.lock index 629c973f..4d93a731 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "react-chartjs-2": "5.3.1", "react-colorful": "5.7.0", "react-dom": "19.2.7", + "react-easy-crop": "^6.2.2", "react-ga4": "3.0.1", "react-hot-toast": "2.6.0", "react-icons": "5.6.0", @@ -34,6 +35,7 @@ "workbox-cacheable-response": "7.4.1", "workbox-expiration": "7.4.1", "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", "workbox-routing": "7.4.1", "workbox-strategies": "7.4.1", "wxt": "0.20.26", @@ -947,6 +949,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-wheel": ["normalize-wheel@1.0.1", "", {}, "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], "nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="], @@ -1039,6 +1043,8 @@ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-easy-crop": ["react-easy-crop@6.2.2", "", { "dependencies": { "normalize-wheel": "^1.0.1" }, "peerDependencies": { "react": ">=16.4.0", "react-dom": ">=16.4.0" } }, "sha512-b0HOicSvLYoNk1yvZTwjH8sNjF5uD9xLtWrSDnv+fx1dXTbwd8bNLQaP6gjXw//A+tni9Mw5KDmPNOEjKF55NQ=="], + "react-ga4": ["react-ga4@3.0.1", "", {}, "sha512-GyCc01bSheWXjzGDyHsXMOqk/SP5Cf/JrcJTg4hcpKx4eeSwaJKpJUc+ipF4ffLTZkmabmf3ZGBv4OKHTXNXyA=="], "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], @@ -1233,6 +1239,8 @@ "workbox-precaching": ["workbox-precaching@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1", "workbox-routing": "7.4.1", "workbox-strategies": "7.4.1" } }, "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q=="], + "workbox-range-requests": ["workbox-range-requests@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ=="], + "workbox-routing": ["workbox-routing@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA=="], "workbox-strategies": ["workbox-strategies@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ=="], diff --git a/package.json b/package.json index a45d9245..62f66e2d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react-chartjs-2": "5.3.1", "react-colorful": "5.7.0", "react-dom": "19.2.7", + "react-easy-crop": "^6.2.2", "react-ga4": "3.0.1", "react-hot-toast": "2.6.0", "react-icons": "5.6.0", diff --git a/src/layouts/setting/tabs/account/components/modals/avatar/avatar-crop.modal.tsx b/src/layouts/setting/tabs/account/components/modals/avatar/avatar-crop.modal.tsx new file mode 100644 index 00000000..43a72075 --- /dev/null +++ b/src/layouts/setting/tabs/account/components/modals/avatar/avatar-crop.modal.tsx @@ -0,0 +1,126 @@ +import { useCallback, useState } from 'react' +import Cropper, { Area, Point } from 'react-easy-crop' +import Modal from '@/components/modal' +import { FooterButtons } from '../footer-buttons' + +interface Prop { + show: boolean + image: string + onClose: () => void + onCropComplete: (file: File) => void +} + +// از روی عکس اصلی و مختصات کراپ‌شده، یک فایل جدید (کراپ‌شده) می‌سازه +async function getCroppedImageFile( + imageSrc: string, + cropArea: Area, + fileName: string +): Promise { + const image = await new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => resolve(img) + img.onerror = reject + img.src = imageSrc + }) + + const canvas = document.createElement('canvas') + canvas.width = cropArea.width + canvas.height = cropArea.height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Canvas context not available') + + ctx.drawImage( + image, + cropArea.x, + cropArea.y, + cropArea.width, + cropArea.height, + 0, + 0, + cropArea.width, + cropArea.height + ) + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('Canvas is empty')) + return + } + resolve(new File([blob], fileName, { type: blob.type || 'image/png' })) + }, 'image/png') + }) +} + +export function AvatarCropModal({ show, image, onClose, onCropComplete }: Prop) { + const [crop, setCrop] = useState({ x: 0, y: 0 }) + const [zoom, setZoom] = useState(1) + const [croppedArea, setCroppedArea] = useState(null) + const [isSaving, setIsSaving] = useState(false) + + const handleCropComplete = useCallback( + (_croppedAreaPercent: Area, croppedAreaPixels: Area) => { + setCroppedArea(croppedAreaPixels) + }, + [] + ) + + const handleConfirm = async () => { + if (!croppedArea) return + setIsSaving(true) + try { + const file = await getCroppedImageFile(image, croppedArea, 'avatar.png') + onCropComplete(file) + } finally { + setIsSaving(false) + } + } + + return ( + onClose()} + title="برش تصویر" + direction="rtl" + showCloseButton + > +
+
+ +
+ +
+ بزرگنمایی + setZoom(Number(e.target.value))} + className="w-full range range-xs" + /> +
+ +
+ +
+
+
+ ) +} diff --git a/src/layouts/setting/tabs/account/components/modals/avatar/edit.avatar.tsx b/src/layouts/setting/tabs/account/components/modals/avatar/edit.avatar.tsx new file mode 100644 index 00000000..44144816 --- /dev/null +++ b/src/layouts/setting/tabs/account/components/modals/avatar/edit.avatar.tsx @@ -0,0 +1,126 @@ +import Analytics from '@/analytics' +import { showToast } from '@/common/toast' +import { AvatarComponent } from '@/components/avatar.component' +import Modal from '@/components/modal' +import { SectionPanel } from '@/components/section-panel' +import { useAuth } from '@/context/auth.context' +import { useUpdateUserProfile } from '@/services/hooks/auth/authService.hook' +import { useRef, useState } from 'react' +import { FooterButtons } from '../footer-buttons' +import { Icon } from '@/src/icons' +import { AvatarCropModal } from './avatar-crop.modal' + +interface Prop { + show: boolean + onClose: () => void +} +export function EditAvatarModal({ show, onClose }: Prop) { + const { refetchUser } = useAuth() + const [avatar, setAvatar] = useState(null) + const [rawImage, setRawImage] = useState(null) + const [showCropper, setShowCropper] = useState(false) + const avatarRef = useRef(null) + const updateProfileMutation = useUpdateUserProfile() + + const onChangeAvatar = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file && file.size > 1024 * 1024) { + showToast('فایل بزرگتر از ۱ مگابایت است', 'error') + return + } + const validTypes = ['image/png', 'image/jpeg', 'image/webp'] + if (file && !validTypes.includes(file.type)) { + showToast('فرمت فایل نامعتبر است', 'error') + return + } + if (file) { + setRawImage(URL.createObjectURL(file)) + setShowCropper(true) + } + // اجازه بده همون فایل دوباره انتخاب بشه + e.target.value = '' + } + + const onCropComplete = (croppedFile: File) => { + setAvatar(croppedFile) + setShowCropper(false) + if (rawImage) { + URL.revokeObjectURL(rawImage) + setRawImage(null) + } + } + + const onCropCancel = () => { + setShowCropper(false) + if (rawImage) { + URL.revokeObjectURL(rawImage) + setRawImage(null) + } + } + + const onSave = async () => { + const data = new FormData() + if (avatar) data.append('avatar', avatar) + + await updateProfileMutation.mutateAsync(data) + await refetchUser() + Analytics.event('avatar_updated') + setTimeout(() => { + onClose() + }, 10) + } + + return ( + <> + onClose} + title=" " + direction="rtl" + showCloseButton={false} + > +
+ +
avatarRef.current?.click()} + > + {avatar ? ( + + ) : ( + + )} +
+ + +
+ + +
+
+ + {rawImage && ( + + )} + + ) +} diff --git a/src/layouts/setting/tabs/account/components/modals/edit.avatar.tsx b/src/layouts/setting/tabs/account/components/modals/edit.avatar.tsx deleted file mode 100644 index a8e44ffc..00000000 --- a/src/layouts/setting/tabs/account/components/modals/edit.avatar.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import Analytics from '@/analytics' -import { showToast } from '@/common/toast' -import { AvatarComponent } from '@/components/avatar.component' -import Modal from '@/components/modal' -import { SectionPanel } from '@/components/section-panel' -import { useAuth } from '@/context/auth.context' -import { useUpdateUserProfile } from '@/services/hooks/auth/authService.hook' -import { useRef, useState } from 'react' -import { FooterButtons } from './footer-buttons' -import { Icon } from '@/src/icons' - -interface Prop { - show: boolean - onClose: () => void -} -export function EditAvatarModal({ show, onClose }: Prop) { - const { refetchUser } = useAuth() - const [avatar, setAvatar] = useState(null) - const avatarRef = useRef(null) - const updateProfileMutation = useUpdateUserProfile() - - const onChangeAvatar = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file && file.size > 1024 * 1024) { - showToast('فایل بزرگتر از ۱ مگابایت است', 'error') - return - } - const validTypes = ['image/png', 'image/jpeg', 'image/webp'] - if (file && !validTypes.includes(file.type)) { - showToast('فرمت فایل نامعتبر است', 'error') - return - } - setAvatar(file || null) - } - - const onSave = async () => { - const data = new FormData() - if (avatar) data.append('avatar', avatar) - - await updateProfileMutation.mutateAsync(data) - await refetchUser() - Analytics.event('avatar_updated') - setTimeout(() => { - onClose() - }, 10) - } - - return ( - onClose} - title=" " - direction="rtl" - showCloseButton={false} - > -
- -
avatarRef.current?.click()} - > - {avatar ? ( - - ) : ( - - )} -
- - -
- - -
-
- ) -} diff --git a/src/layouts/setting/tabs/account/components/profile-display.tsx b/src/layouts/setting/tabs/account/components/profile-display.tsx index ff674400..bdc34e8c 100644 --- a/src/layouts/setting/tabs/account/components/profile-display.tsx +++ b/src/layouts/setting/tabs/account/components/profile-display.tsx @@ -14,7 +14,7 @@ import { ChangeOccupationModal } from './modals/edit-occupation' import { ChangeInterestsModal } from './modals/edit-interests' import { ChangeNameModal } from './modals/edit-name' import { ChangeCityModal } from './modals/edit-city' -import { EditAvatarModal } from './modals/edit.avatar' +import { EditAvatarModal } from './modals/avatar/edit.avatar' import { AddEmailModal } from './modals/add-email.modal' import { ChangeUsernameModal } from './modals/edit-username' import { Icon } from '@/src/icons'