17.05.2026 funny

This commit is contained in:
2026-05-17 14:56:59 +03:00
parent 01d72f4885
commit 2a48d4a4c5
11 changed files with 424 additions and 69 deletions

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

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

View File

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

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