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 МБ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Перетащите для позиционирования · колёсико / слайдер для масштаба
+
+
+
+
+
+
+
+