feat: красивое модальное окно редактирования аватара с кропом и зумом

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 21:10:42 +03:00
parent c2eb319162
commit 366ad6e13e
+516 -32
View File
@@ -592,6 +592,132 @@
font-family: monospace; line-height: 1.5; font-family: monospace; line-height: 1.5;
} }
.pl-limit { font-size: 0.72rem; color: var(--text-3); margin-top: 10px; } .pl-limit { font-size: 0.72rem; color: var(--text-3); margin-top: 10px; }
/* ══════════ Avatar Modal ══════════ */
.av-ovl {
position: fixed; inset: 0; z-index: 900;
background: rgba(0,0,0,0.68); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .22s;
}
.av-ovl.open { opacity: 1; pointer-events: auto; }
.av-dlg {
width: 380px; max-width: calc(100vw - 32px);
background: linear-gradient(170deg, #0d0b28 0%, #1a1248 100%);
border: 1px solid rgba(155,93,229,0.3);
border-radius: 24px;
box-shadow: 0 40px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(155,93,229,0.12);
overflow: hidden;
transform: scale(.94) translateY(14px);
transition: transform .28s cubic-bezier(.34,1.56,.64,1);
}
.av-ovl.open .av-dlg { transform: scale(1) translateY(0); }
.av-hdr {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px 16px;
border-bottom: 1px solid rgba(255,255,255,0.07);
background: linear-gradient(90deg, rgba(155,93,229,0.08), transparent);
}
.av-title {
font-family: 'Unbounded', sans-serif; font-size: 0.83rem; font-weight: 800; color: #fff;
display: flex; align-items: center; gap: 9px;
}
.av-title-dot {
width: 8px; height: 8px; border-radius: 50%;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
flex-shrink: 0;
}
.av-close {
width: 30px; height: 30px; border-radius: 8px; border: none;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.45);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all .15s;
}
.av-close:hover { background: rgba(241,91,181,0.15); color: #F15BB5; }
.av-close svg { width: 14px; height: 14px; }
/* Steps */
.av-step { padding: 22px 20px; display: flex; flex-direction: column; align-items: center; gap: 16px; }
/* Current avatar preview */
.av-cur {
width: 96px; height: 96px; border-radius: 50%;
background: linear-gradient(135deg, rgba(155,93,229,0.6), rgba(6,214,224,0.4));
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 1.6rem; font-weight: 800; color: #fff;
overflow: hidden;
box-shadow: 0 0 0 2px rgba(155,93,229,0.5), 0 0 0 5px rgba(155,93,229,0.12), 0 8px 24px rgba(0,0,0,0.4);
}
.av-cur img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
/* Modal status */
.av-status-row {
font-size: 0.72rem; font-weight: 600; padding: 5px 14px;
border-radius: 99px; text-align: center; display: none;
}
.av-status-row.pending { display: block; background: rgba(255,200,0,0.14); color: #ffd166; }
.av-status-row.rejected { display: block; background: rgba(241,91,181,0.14); color: #F15BB5; }
/* Drop zone */
.av-drop {
width: 100%; padding: 22px 16px; box-sizing: border-box;
border: 2px dashed rgba(155,93,229,0.35); border-radius: 16px;
cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 6px;
transition: all .2s; background: rgba(155,93,229,0.04);
}
.av-drop:hover, .av-drop.drag-over {
border-color: #9B5DE5; background: rgba(155,93,229,0.10);
transform: translateY(-1px);
}
.av-drop-icon { width: 30px; height: 30px; color: rgba(155,93,229,0.65); margin-bottom: 2px; }
.av-drop-txt { font-size: 0.84rem; font-weight: 600; color: rgba(255,255,255,0.7); text-align: center; }
.av-drop-sub { font-size: 0.68rem; color: rgba(255,255,255,0.3); }
/* Delete button */
.av-del-btn {
display: none; align-items: center; gap: 7px;
padding: 7px 16px; border-radius: 99px;
border: 1.5px solid rgba(241,91,181,0.3);
background: rgba(241,91,181,0.06);
color: rgba(241,91,181,0.75); font-size: 0.77rem; font-weight: 600;
cursor: pointer; transition: all .15s; font-family: 'Manrope', sans-serif;
}
.av-del-btn.visible { display: flex; }
.av-del-btn:hover { border-color: #F15BB5; background: rgba(241,91,181,0.14); color: #F15BB5; }
.av-del-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
/* Crop canvas */
#av-canvas {
border-radius: 50%; cursor: grab; display: block;
box-shadow: 0 4px 32px rgba(0,0,0,0.5), 0 0 0 2px rgba(155,93,229,0.3);
touch-action: none;
}
#av-canvas:active { cursor: grabbing; }
/* Zoom row */
.av-zoom-row { display: flex; align-items: center; gap: 10px; width: 100%; }
.av-zoom-row svg { flex-shrink: 0; color: rgba(255,255,255,0.4); }
#av-zoom {
flex: 1; -webkit-appearance: none; appearance: none; height: 4px;
border-radius: 99px; background: rgba(255,255,255,0.12); cursor: pointer; outline: none;
}
#av-zoom::-webkit-slider-thumb {
-webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
box-shadow: 0 1px 4px rgba(0,0,0,0.4); cursor: pointer;
}
.av-crop-hint { font-size: 0.67rem; color: rgba(255,255,255,0.28); text-align: center; }
/* Crop buttons */
.av-crop-btns { display: flex; gap: 10px; width: 100%; }
.av-btn-back {
flex: 1; padding: 10px 16px; border-radius: 12px;
border: 1.5px solid rgba(255,255,255,0.1);
background: transparent; color: rgba(255,255,255,0.5);
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
cursor: pointer; transition: all .15s;
}
.av-btn-back:hover { border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.8); }
.av-btn-send {
flex: 2; padding: 10px 16px; border-radius: 12px; border: none;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: opacity .15s;
}
.av-btn-send:hover { opacity: 0.88; }
.av-btn-send:disabled { opacity: 0.45; cursor: not-allowed; }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
</head> </head>
@@ -616,14 +742,12 @@
<div class="p-avatar" id="big-avatar">LS</div> <div class="p-avatar" id="big-avatar">LS</div>
<!-- Edit button — shown only for students via JS --> <!-- Edit button — shown only for students via JS -->
<button class="p-avatar-edit-btn" id="p-avatar-edit-btn" title="Изменить аватар" style="display:none" <button class="p-avatar-edit-btn" id="p-avatar-edit-btn" title="Изменить аватар" style="display:none"
onclick="document.getElementById('p-avatar-input').click()"> onclick="avModalOpen()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/> <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg> </svg>
</button> </button>
<input type="file" id="p-avatar-input" accept="image/png,image/jpeg,image/webp"
style="display:none" onchange="avatarFileSelected(this)">
</div> </div>
<div class="p-avatar-status" id="p-avatar-status"></div> <div class="p-avatar-status" id="p-avatar-status"></div>
@@ -1068,7 +1192,9 @@
const { user, isTeacher, isAdmin } = LS.initPage(); const { user, isTeacher, isAdmin } = LS.initPage();
LS.showBoardIfAllowed(); LS.showBoardIfAllowed();
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('av-modal').classList.contains('open')) avClose();
});
const ROLE_LABELS = { student:'Ученик', teacher:'Учитель', admin:'Администратор' }; const ROLE_LABELS = { student:'Ученик', teacher:'Учитель', admin:'Администратор' };
const SUBJ_META = { const SUBJ_META = {
@@ -1151,34 +1277,6 @@
} catch {} } catch {}
} }
async function avatarFileSelected(input) {
const file = input.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) { LS.toast('Файл слишком большой (макс. 2 МБ)', 'error'); return; }
const fd = new FormData();
fd.append('avatar', file);
try {
const token = LS.getToken();
const res = await fetch('/api/avatar/request', {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: fd,
});
if (!res.ok) throw new Error();
LS.toast('Аватар отправлен на проверку', 'success');
// Show pending preview locally
const url = URL.createObjectURL(file);
const wrap = document.getElementById('big-avatar');
wrap.innerHTML = `<img src="${url}" alt="Аватар" style="opacity:.6">`;
document.getElementById('p-avatar-status').className = 'p-avatar-status pending';
document.getElementById('p-avatar-status').textContent = 'На проверке';
} catch {
LS.toast('Ошибка загрузки', 'error');
}
input.value = '';
}
/* ── Student ── */ /* ── Student ── */
async function loadStudentStats() { async function loadStudentStats() {
@@ -1741,8 +1839,394 @@
}); });
} }
} }
/* ══════════════════════════════════════════════════
Avatar Modal
══════════════════════════════════════════════════ */
let _avImg = null, _avZoom = 1, _avOffX = 0, _avOffY = 0;
let _avDrag = false, _avDragStartX = 0, _avDragStartY = 0;
let _avLastTouchDist = 0;
function avModalOpen() {
const modal = document.getElementById('av-modal');
// Mirror current avatar into modal preview
const bigAv = document.getElementById('big-avatar');
const preEl = document.getElementById('av-modal-cur');
const img = bigAv.querySelector('img');
if (img) {
preEl.innerHTML = `<img src="${img.src}" alt="Аватар">`;
} else {
preEl.innerHTML = '';
preEl.textContent = bigAv.textContent || 'LS';
}
// Show delete btn only if has actual avatar
document.getElementById('av-del-btn').classList.toggle('visible', !!img);
avLoadStatus();
modal.classList.add('open');
if (LS.sfx) LS.sfx.play('modal_open');
}
function avClose() {
document.getElementById('av-modal').classList.remove('open');
avBack();
if (LS.sfx) LS.sfx.play('modal_close');
}
function avModalClickOutside(e) {
if (e.target === document.getElementById('av-modal')) avClose();
}
/* ── Step 1: Drag & drop ── */
function avDragOver(e) { e.preventDefault(); document.getElementById('av-drop').classList.add('drag-over'); }
function avDragLeave() { document.getElementById('av-drop').classList.remove('drag-over'); }
function avDrop(e) {
e.preventDefault();
document.getElementById('av-drop').classList.remove('drag-over');
const f = e.dataTransfer.files[0];
if (f) avLoadFile(f);
}
function avFileChosen(inp) { if (inp.files[0]) avLoadFile(inp.files[0]); inp.value = ''; }
function avLoadFile(file) {
if (file.size > 2 * 1024 * 1024) { LS.toast('Файл слишком большой (макс. 2 МБ)', 'error'); return; }
if (!['image/png','image/jpeg','image/webp'].includes(file.type)) { LS.toast('Формат не поддерживается', 'error'); return; }
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => {
_avImg = img;
_avZoom = 1;
_avOffX = 0;
_avOffY = 0;
document.getElementById('av-zoom').value = 100;
document.getElementById('av-s1').style.display = 'none';
document.getElementById('av-s2').style.display = 'flex';
avDraw();
avBindCanvas();
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
}
function avBack() {
document.getElementById('av-s1').style.display = 'flex';
document.getElementById('av-s2').style.display = 'none';
_avImg = null;
_avDrag = false;
}
/* ── Step 2: Canvas crop ── */
function avDraw() {
const c = document.getElementById('av-canvas');
const ctx = c.getContext('2d');
const S = c.width; // 280
const r = S / 2 - 5;
if (!_avImg) return;
const minScale = S / Math.min(_avImg.naturalWidth, _avImg.naturalHeight);
const scale = minScale * _avZoom;
const iw = _avImg.naturalWidth * scale;
const ih = _avImg.naturalHeight * scale;
const ix = S / 2 - iw / 2 + _avOffX;
const iy = S / 2 - ih / 2 + _avOffY;
ctx.clearRect(0, 0, S, S);
// Dark background
ctx.fillStyle = '#1a1248';
ctx.fillRect(0, 0, S, S);
// Dimmed full image (outside circle)
ctx.globalAlpha = 0.22;
ctx.drawImage(_avImg, ix, iy, iw, ih);
ctx.globalAlpha = 1;
// Full-brightness image clipped to circle
ctx.save();
ctx.beginPath();
ctx.arc(S / 2, S / 2, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(_avImg, ix, iy, iw, ih);
ctx.restore();
// Dashed circle border
ctx.strokeStyle = 'rgba(155,93,229,0.8)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.arc(S / 2, S / 2, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
function avZoomChange(v) {
_avZoom = v / 100;
avDraw();
}
function avBindCanvas() {
const c = document.getElementById('av-canvas');
c.onmousedown = e => {
_avDrag = true;
_avDragStartX = e.clientX - _avOffX;
_avDragStartY = e.clientY - _avOffY;
e.preventDefault();
};
c.onmousemove = e => {
if (!_avDrag) return;
_avOffX = e.clientX - _avDragStartX;
_avOffY = e.clientY - _avDragStartY;
avDraw();
};
c.onmouseup = () => { _avDrag = false; };
c.onmouseleave = () => { _avDrag = false; };
c.onwheel = e => {
e.preventDefault();
_avZoom = Math.max(0.5, Math.min(3.5, _avZoom - e.deltaY * 0.0012));
document.getElementById('av-zoom').value = _avZoom * 100;
avDraw();
};
// Touch: drag + pinch-to-zoom
c.ontouchstart = e => {
e.preventDefault();
if (e.touches.length === 1) {
_avDrag = true;
_avDragStartX = e.touches[0].clientX - _avOffX;
_avDragStartY = e.touches[0].clientY - _avOffY;
} else if (e.touches.length === 2) {
_avDrag = false;
_avLastTouchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
}
};
c.ontouchmove = e => {
e.preventDefault();
if (e.touches.length === 1 && _avDrag) {
_avOffX = e.touches[0].clientX - _avDragStartX;
_avOffY = e.touches[0].clientY - _avDragStartY;
avDraw();
} else if (e.touches.length === 2) {
const d = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
if (_avLastTouchDist) {
_avZoom = Math.max(0.5, Math.min(3.5, _avZoom * (d / _avLastTouchDist)));
document.getElementById('av-zoom').value = _avZoom * 100;
avDraw();
}
_avLastTouchDist = d;
}
};
c.ontouchend = () => { _avDrag = false; _avLastTouchDist = 0; };
}
/* ── Crop, render to 400×400, upload ── */
async function avSubmit() {
const btn = document.getElementById('av-btn-send');
btn.disabled = true;
btn.textContent = 'Отправка…';
try {
const S = 280;
const OUT = 400;
const out = document.createElement('canvas');
out.width = out.height = OUT;
const oc = out.getContext('2d');
const minScale = S / Math.min(_avImg.naturalWidth, _avImg.naturalHeight);
const ratio = OUT / S;
const scale = minScale * _avZoom * ratio;
const iw = _avImg.naturalWidth * scale;
const ih = _avImg.naturalHeight * scale;
const ix = OUT / 2 - iw / 2 + _avOffX * ratio;
const iy = OUT / 2 - ih / 2 + _avOffY * ratio;
oc.beginPath();
oc.arc(OUT / 2, OUT / 2, OUT / 2, 0, Math.PI * 2);
oc.clip();
oc.drawImage(_avImg, ix, iy, iw, ih);
out.toBlob(async blob => {
try {
const fd = new FormData();
fd.append('avatar', blob, 'avatar.jpg');
const token = LS.getToken();
const res = await fetch('/api/avatar/request', {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: fd,
});
if (!res.ok) throw new Error();
const blobUrl = URL.createObjectURL(blob);
// Update left-panel avatar (pending opacity)
document.getElementById('big-avatar').innerHTML =
`<img src="${blobUrl}" alt="Аватар" style="opacity:.55">`;
const pSt = document.getElementById('p-avatar-status');
pSt.className = 'p-avatar-status pending';
pSt.textContent = 'На проверке';
// Update modal preview
const preEl = document.getElementById('av-modal-cur');
preEl.innerHTML = `<img src="${blobUrl}" alt="Аватар" style="opacity:.55">`;
document.getElementById('av-del-btn').classList.add('visible');
LS.toast('Аватар отправлен на проверку', 'success');
if (LS.sfx) LS.sfx.play('success');
avBack();
avLoadStatus();
} catch {
LS.toast('Ошибка загрузки', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Отправить на проверку';
}
}, 'image/jpeg', 0.92);
} catch {
LS.toast('Ошибка обработки изображения', 'error');
btn.disabled = false;
btn.textContent = 'Отправить на проверку';
}
}
/* ── Delete avatar ── */
async function avDelete() {
if (!confirm('Удалить аватар?')) return;
try {
const token = LS.getToken();
const res = await fetch('/api/avatar/me', {
method: 'DELETE',
headers: token ? { Authorization: 'Bearer ' + token } : {},
});
if (!res.ok) throw new Error();
const initials = (document.getElementById('profile-name').textContent || 'LS')
.split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
const bigAv = document.getElementById('big-avatar');
bigAv.innerHTML = '';
bigAv.textContent = initials;
const preEl = document.getElementById('av-modal-cur');
preEl.innerHTML = '';
preEl.textContent = initials;
document.getElementById('p-avatar-status').className = 'p-avatar-status';
document.getElementById('av-del-btn').classList.remove('visible');
document.getElementById('av-modal-status').className = 'av-status-row';
LS.toast('Аватар удалён', 'success');
avClose();
} catch {
LS.toast('Ошибка удаления', 'error');
}
}
/* ── Load moderation status into modal ── */
async function avLoadStatus() {
try {
const data = await LS.get('/api/avatar/my-status');
const el = document.getElementById('av-modal-status');
if (!data.request || data.request.status === 'approved') {
el.className = 'av-status-row'; return;
}
const r = data.request;
if (r.status === 'pending') {
el.className = 'av-status-row pending';
el.textContent = 'Фото отправлено — ожидает проверки';
} else if (r.status === 'rejected') {
el.className = 'av-status-row rejected';
el.textContent = r.reject_msg ? 'Отклонено: ' + r.reject_msg : 'Фото отклонено';
}
} catch {}
}
</script> </script>
<script src="/js/search.js"></script> <script src="/js/search.js"></script>
<script src="/js/mobile.js"></script> <script src="/js/mobile.js"></script>
<!-- ══ Avatar Modal ══ -->
<div class="av-ovl" id="av-modal" onclick="avModalClickOutside(event)">
<div class="av-dlg" onclick="event.stopPropagation()">
<!-- Header -->
<div class="av-hdr">
<span class="av-title">
<span class="av-title-dot"></span>
Аватар профиля
</span>
<button class="av-close" onclick="avClose()" aria-label="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Step 1: Select / Upload -->
<div class="av-step" id="av-s1">
<!-- Current avatar (large) -->
<div class="av-cur" id="av-modal-cur">LS</div>
<!-- Status (pending / rejected) -->
<div class="av-status-row" id="av-modal-status"></div>
<!-- Drop zone -->
<div class="av-drop" id="av-drop"
ondragover="avDragOver(event)" ondragleave="avDragLeave(event)" ondrop="avDrop(event)"
onclick="document.getElementById('av-file-inp').click()" role="button" tabindex="0"
onkeydown="if(event.key==='Enter'||event.key===' ')this.click()">
<svg class="av-drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="av-drop-txt">Перетащите фото или нажмите для выбора</div>
<div class="av-drop-sub">PNG, JPG, WebP · до 2 МБ</div>
<input type="file" id="av-file-inp" accept="image/png,image/jpeg,image/webp"
style="display:none" onchange="avFileChosen(this)">
</div>
<!-- Delete current avatar -->
<button class="av-del-btn" id="av-del-btn" onclick="avDelete()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
</svg>
Удалить аватар
</button>
</div>
<!-- Step 2: Crop / Zoom -->
<div class="av-step" id="av-s2" style="display:none">
<canvas id="av-canvas" width="280" height="280"></canvas>
<div class="av-zoom-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/>
</svg>
<input type="range" id="av-zoom" min="100" max="350" value="100" oninput="avZoomChange(this.value)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/><line x1="11" y1="8" x2="11" y2="14"/>
</svg>
</div>
<div class="av-crop-hint">Перетащите для позиционирования · колёсико / слайдер для масштаба</div>
<div class="av-crop-btns">
<button class="av-btn-back" onclick="avBack()">Назад</button>
<button class="av-btn-send" id="av-btn-send" onclick="avSubmit()">Отправить на проверку</button>
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>