From 366ad6e13e8819cbb4be94d93498767dcc718bc7 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 14 Apr 2026 21:10:42 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BA=D1=80=D0=B0=D1=81=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=B0=20=D1=81=20=D0=BA?= =?UTF-8?q?=D1=80=D0=BE=D0=BF=D0=BE=D0=BC=20=D0=B8=20=D0=B7=D1=83=D0=BC?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/profile.html | 548 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 516 insertions(+), 32 deletions(-) diff --git a/frontend/profile.html b/frontend/profile.html index 8427473..5de4e10 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -592,6 +592,132 @@ font-family: monospace; line-height: 1.5; } .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; } @@ -616,14 +742,12 @@
LS
-
@@ -1068,7 +1192,9 @@ const { user, isTeacher, isAdmin } = LS.initPage(); 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 SUBJ_META = { @@ -1151,34 +1277,6 @@ } 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 = `Аватар`; - document.getElementById('p-avatar-status').className = 'p-avatar-status pending'; - document.getElementById('p-avatar-status').textContent = 'На проверке'; - } catch { - LS.toast('Ошибка загрузки', 'error'); - } - input.value = ''; - } /* ── Student ── */ 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 = `Аватар`; + } 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 = + `Аватар`; + 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 = `Аватар`; + 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 {} + } + + +
+
+ + +
+ + + Аватар профиля + + +
+ + +
+ +
LS
+ + +
+ + +
+ + + + + +
Перетащите фото или нажмите для выбора
+
PNG, JPG, WebP · до 2 МБ
+ +
+ + + +
+ + + + +
+