17.05.2026 funny
This commit is contained in:
60
dist/assets/index-BmeSmmZx.js
vendored
60
dist/assets/index-BmeSmmZx.js
vendored
File diff suppressed because one or more lines are too long
153
dist/assets/index-DGfl_oNm.js
vendored
Normal file
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
4
dist/index.html
vendored
@@ -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
27
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user