17.05.2026 funny
This commit is contained in:
66
src/widgets/profile/ui/AvatarCropModal.module.css
Normal file
66
src/widgets/profile/ui/AvatarCropModal.module.css
Normal file
@@ -0,0 +1,66 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-mid);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cropArea {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zoom {
|
||||
flex: 1;
|
||||
accent-color: var(--interactive);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
}
|
||||
89
src/widgets/profile/ui/AvatarCropModal.tsx
Normal file
89
src/widgets/profile/ui/AvatarCropModal.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Cropper, { type Area } from 'react-easy-crop'
|
||||
import { Button } from '@shared/ui'
|
||||
import { getCroppedImg } from './getCroppedImg'
|
||||
import styles from './AvatarCropModal.module.css'
|
||||
|
||||
interface Props {
|
||||
imageSrc: string
|
||||
isSaving?: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (blob: Blob) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function AvatarCropModal({ imageSrc, isSaving, onCancel, onConfirm }: Props) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pixels, setPixels] = useState<Area | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleComplete = useCallback((_: Area, areaPixels: Area) => {
|
||||
setPixels(areaPixels)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isSaving) onCancel()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onCancel, isSaving])
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!pixels) return
|
||||
setError(null)
|
||||
try {
|
||||
const blob = await getCroppedImg(imageSrc, pixels)
|
||||
await onConfirm(blob)
|
||||
} catch {
|
||||
setError('Не удалось обрезать изображение')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.backdrop} onClick={isSaving ? undefined : onCancel}>
|
||||
<div className={styles.card} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className={styles.title}>Выберите область аватара</h3>
|
||||
|
||||
<div className={styles.cropArea}>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={handleComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<label htmlFor="avatar-zoom">Масштаб</label>
|
||||
<input
|
||||
id="avatar-zoom"
|
||||
className={styles.zoom}
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={isSaving}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleConfirm} disabled={isSaving || !pixels}>
|
||||
{isSaving ? 'Загрузка...' : 'Сохранить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@shared/ui'
|
||||
import { useMe, useUploadAvatar } from '@features/auth'
|
||||
import { AvatarCropModal } from './AvatarCropModal'
|
||||
import styles from './ProfileAvatar.module.css'
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
@@ -12,7 +13,7 @@ function fileToBase64(file: File): Promise<string> {
|
||||
resolve(comma >= 0 ? result.slice(comma + 1) : result)
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export function ProfileAvatar() {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [imgFailed, setImgFailed] = useState(false)
|
||||
const [pendingPreview, setPendingPreview] = useState<string | null>(null)
|
||||
|
||||
const avatarLink = data?.avatar_link ?? null
|
||||
const showImg = avatarLink && !imgFailed
|
||||
@@ -30,22 +32,39 @@ export function ProfileAvatar() {
|
||||
setImgFailed(false)
|
||||
}, [avatarLink])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingPreview) URL.revokeObjectURL(pendingPreview)
|
||||
}
|
||||
}, [pendingPreview])
|
||||
|
||||
const openPicker = () => {
|
||||
if (isPending) return
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
|
||||
setError(null)
|
||||
setPendingPreview(URL.createObjectURL(file))
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
if (pendingPreview) URL.revokeObjectURL(pendingPreview)
|
||||
setPendingPreview(null)
|
||||
}
|
||||
|
||||
const handleConfirm = async (blob: Blob) => {
|
||||
try {
|
||||
const photo_base64 = await fileToBase64(file)
|
||||
await upload({ photo_base64, decoded_bytes: String(file.size) })
|
||||
const photo_base64 = await blobToBase64(blob)
|
||||
await upload({ photo_base64, decoded_bytes: String(blob.size) })
|
||||
closeModal()
|
||||
} catch {
|
||||
setError('Не удалось загрузить фото')
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +108,15 @@ export function ProfileAvatar() {
|
||||
</Button>
|
||||
</div>
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
|
||||
{pendingPreview && (
|
||||
<AvatarCropModal
|
||||
imageSrc={pendingPreview}
|
||||
isSaving={isPending}
|
||||
onCancel={closeModal}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
51
src/widgets/profile/ui/getCroppedImg.ts
Normal file
51
src/widgets/profile/ui/getCroppedImg.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface PixelCrop {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('Не удалось загрузить изображение'))
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCroppedImg(
|
||||
imageSrc: string,
|
||||
pixelCrop: PixelCrop,
|
||||
outputSize = 512,
|
||||
): Promise<Blob> {
|
||||
const image = await loadImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = outputSize
|
||||
canvas.height = outputSize
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas 2D context недоступен')
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
outputSize,
|
||||
outputSize,
|
||||
)
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error('Пустой Blob после обрезки'))
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user