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

File diff suppressed because one or more lines are too long

153
dist/assets/index-DGfl_oNm.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title> <title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-BmeSmmZx.js"></script> <script type="module" crossorigin src="/assets/index-DGfl_oNm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChMX4U7G.css"> <link rel="stylesheet" crossorigin href="/assets/index-fOMxenQH.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

27
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-easy-crop": "^5.5.7",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router-dom": "^7.1.5", "react-router-dom": "^7.1.5",
"zod": "^3.24.1" "zod": "^3.24.1"
@@ -3055,6 +3056,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3237,6 +3244,20 @@
"react": "^19.2.5" "react": "^19.2.5"
} }
}, },
"node_modules/react-easy-crop": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz",
"integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1",
"tslib": "^2.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-redux": { "node_modules/react-redux": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
@@ -3516,6 +3537,12 @@
} }
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -15,6 +15,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-easy-crop": "^5.5.7",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-router-dom": "^7.1.5", "react-router-dom": "^7.1.5",
"zod": "^3.24.1" "zod": "^3.24.1"

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 { useEffect, useRef, useState } from 'react'
import { Button } from '@shared/ui' import { Button } from '@shared/ui'
import { useMe, useUploadAvatar } from '@features/auth' import { useMe, useUploadAvatar } from '@features/auth'
import { AvatarCropModal } from './AvatarCropModal'
import styles from './ProfileAvatar.module.css' import styles from './ProfileAvatar.module.css'
function fileToBase64(file: File): Promise<string> { function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
@@ -12,7 +13,7 @@ function fileToBase64(file: File): Promise<string> {
resolve(comma >= 0 ? result.slice(comma + 1) : result) resolve(comma >= 0 ? result.slice(comma + 1) : result)
} }
reader.onerror = () => reject(reader.error) reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file) reader.readAsDataURL(blob)
}) })
} }
@@ -22,6 +23,7 @@ export function ProfileAvatar() {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [imgFailed, setImgFailed] = useState(false) const [imgFailed, setImgFailed] = useState(false)
const [pendingPreview, setPendingPreview] = useState<string | null>(null)
const avatarLink = data?.avatar_link ?? null const avatarLink = data?.avatar_link ?? null
const showImg = avatarLink && !imgFailed const showImg = avatarLink && !imgFailed
@@ -30,22 +32,39 @@ export function ProfileAvatar() {
setImgFailed(false) setImgFailed(false)
}, [avatarLink]) }, [avatarLink])
useEffect(() => {
return () => {
if (pendingPreview) URL.revokeObjectURL(pendingPreview)
}
}, [pendingPreview])
const openPicker = () => { const openPicker = () => {
if (isPending) return if (isPending) return
inputRef.current?.click() inputRef.current?.click()
} }
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
e.target.value = '' e.target.value = ''
if (!file) return if (!file) return
setError(null) setError(null)
setPendingPreview(URL.createObjectURL(file))
}
const closeModal = () => {
if (pendingPreview) URL.revokeObjectURL(pendingPreview)
setPendingPreview(null)
}
const handleConfirm = async (blob: Blob) => {
try { try {
const photo_base64 = await fileToBase64(file) const photo_base64 = await blobToBase64(blob)
await upload({ photo_base64, decoded_bytes: String(file.size) }) await upload({ photo_base64, decoded_bytes: String(blob.size) })
closeModal()
} catch { } catch {
setError('Не удалось загрузить фото') setError('Не удалось загрузить фото')
closeModal()
} }
} }
@@ -89,6 +108,15 @@ export function ProfileAvatar() {
</Button> </Button>
</div> </div>
{error && <span className={styles.error}>{error}</span>} {error && <span className={styles.error}>{error}</span>}
{pendingPreview && (
<AvatarCropModal
imageSrc={pendingPreview}
isSaving={isPending}
onCancel={closeModal}
onConfirm={handleConfirm}
/>
)}
</div> </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,
)
})
}

File diff suppressed because one or more lines are too long