Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<File> {
const image = await new Promise<HTMLImageElement>((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<Point>({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedArea, setCroppedArea] = useState<Area | null>(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 (
<Modal
isOpen={show}
onClose={() => onClose()}
title="برش تصویر"
direction="rtl"
showCloseButton
>
<div className="flex flex-col w-full h-96">
<div className="relative flex-1 overflow-hidden rounded-lg bg-base-300">
<Cropper
image={image}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={handleCropComplete}
/>
</div>

<div className="flex items-center gap-3 px-2 mt-4">
<span className="text-xs text-muted">بزرگنمایی</span>
<input
type="range"
min={1}
max={3}
step={0.1}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="w-full range range-xs"
/>
</div>

<div className="mt-4">
<FooterButtons
handleConfirm={handleConfirm}
handleCancel={onClose}
isPending={isSaving}
/>
</div>
</div>
</Modal>
)
}
Original file line number Diff line number Diff line change
@@ -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<File | null>(null)
const [rawImage, setRawImage] = useState<string | null>(null)
const [showCropper, setShowCropper] = useState(false)
const avatarRef = useRef<HTMLInputElement | null>(null)
const updateProfileMutation = useUpdateUserProfile()

const onChangeAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<Modal
isOpen={show}
onClose={() => onClose}
title=" "
direction="rtl"
showCloseButton={false}
>
<div className="flex flex-col justify-between w-full h-52">
<SectionPanel title="تغییر آواتار" size="xs">
<div
className="flex items-center justify-center mx-auto mt-8 transition-all duration-200 ease-out border border-dashed rounded-full cursor-pointer active:scale-95 text-muted h-14 w-14 border-base-content/70 hover:scale-95"
onClick={() => avatarRef.current?.click()}
>
{avatar ? (
<AvatarComponent
file={avatar}
size="lg"
className="transition-all cursor-pointer ring-4 ring-primary/5"
/>
) : (
<Icon name="cameraPlus" />
)}
</div>

<input
type="file"
ref={avatarRef}
accept="image/*"
onChange={onChangeAvatar}
className="hidden"
/>
</SectionPanel>

<FooterButtons
handleConfirm={onSave}
handleCancel={onClose}
isPending={updateProfileMutation.isPending}
/>
</div>
</Modal>

{rawImage && (
<AvatarCropModal
show={showCropper}
image={rawImage}
onClose={onCropCancel}
onCropComplete={onCropComplete}
/>
)}
</>
)
}
90 changes: 0 additions & 90 deletions src/layouts/setting/tabs/account/components/modals/edit.avatar.tsx

This file was deleted.

Loading
Loading