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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
|
||||
<script type="module" crossorigin src="/assets/index-BmeSmmZx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ChMX4U7G.css">
|
||||
<script type="module" crossorigin src="/assets/index-DGfl_oNm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-fOMxenQH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"zod": "^3.24.1"
|
||||
@@ -3055,6 +3056,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -3237,6 +3244,20 @@
|
||||
"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": {
|
||||
"version": "9.2.0",
|
||||
"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": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"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 { 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user