diff --git a/backend/src/controllers/avatarController.js b/backend/src/controllers/avatarController.js index 8e7d7c1..602d254 100644 --- a/backend/src/controllers/avatarController.js +++ b/backend/src/controllers/avatarController.js @@ -98,10 +98,49 @@ function rejectAvatar(req, res) { function removeAvatar(req, res) { const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id); if (user?.avatar_url) { - try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {} + if (!/^preset_\d{2}\.png$/.test(user.avatar_url)) { + try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {} + } db.prepare('UPDATE users SET avatar_url=NULL WHERE id=?').run(req.user.id); } res.json({ ok: true }); } -module.exports = { requestAvatar, myStatus, getPending, approveAvatar, rejectAvatar, removeAvatar }; +/* ── GET /api/avatar/presets ── list available preset avatars ─────────────── */ +function listPresets(_req, res) { + const files = fs.readdirSync(AVATARS_DIR) + .filter(f => /^preset_\d{2}\.png$/.test(f)) + .sort(); + res.json({ presets: files }); +} + +/* ── POST /api/avatar/preset ── set avatar to a preset (no moderation) ────── */ +function setPreset(req, res) { + const filename = String(req.body.filename || ''); + if (!/^preset_\d{2}\.png$/.test(filename)) { + return res.status(400).json({ error: 'Некорректный пресет' }); + } + if (!fs.existsSync(path.join(AVATARS_DIR, filename))) { + return res.status(404).json({ error: 'Пресет не найден' }); + } + + // Remove old uploaded avatar file (but never delete preset files) + const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id); + if (user?.avatar_url && !/^preset_\d{2}\.png$/.test(user.avatar_url)) { + try { fs.unlinkSync(path.join(AVATARS_DIR, user.avatar_url)); } catch {} + } + + // Cancel any pending moderation request from this user + const prev = db.prepare( + "SELECT filename FROM avatar_requests WHERE user_id=? AND status='pending'" + ).get(req.user.id); + if (prev) { + try { fs.unlinkSync(path.join(AVATARS_DIR, prev.filename)); } catch {} + db.prepare("DELETE FROM avatar_requests WHERE user_id=? AND status='pending'").run(req.user.id); + } + + db.prepare('UPDATE users SET avatar_url=? WHERE id=?').run(filename, req.user.id); + res.json({ ok: true, avatar_url: filename }); +} + +module.exports = { requestAvatar, myStatus, getPending, approveAvatar, rejectAvatar, removeAvatar, listPresets, setPreset }; diff --git a/backend/src/routes/avatar.js b/backend/src/routes/avatar.js index 380eda2..6aeefad 100644 --- a/backend/src/routes/avatar.js +++ b/backend/src/routes/avatar.js @@ -32,6 +32,10 @@ router.post('/request', authMiddleware, upload.single('avatar'), ctrl.requestA router.get('/my-status', authMiddleware, ctrl.myStatus); router.delete('/me', authMiddleware, ctrl.removeAvatar); +/* ── preset avatars (available to all roles, no moderation) ─────────────── */ +router.get('/presets', authMiddleware, ctrl.listPresets); +router.post('/preset', authMiddleware, ctrl.setPreset); + /* ── moderator routes (teacher or admin) ────────────────────────────────── */ router.get('/pending', authMiddleware, requireRole('teacher', 'admin'), ctrl.getPending); router.post('/:id/approve', authMiddleware, requireRole('teacher', 'admin'), ctrl.approveAvatar); diff --git a/backend/uploads/avatars/preset_01.png b/backend/uploads/avatars/preset_01.png new file mode 100644 index 0000000..6c50ebb Binary files /dev/null and b/backend/uploads/avatars/preset_01.png differ diff --git a/backend/uploads/avatars/preset_02.png b/backend/uploads/avatars/preset_02.png new file mode 100644 index 0000000..ea39d2e Binary files /dev/null and b/backend/uploads/avatars/preset_02.png differ diff --git a/backend/uploads/avatars/preset_03.png b/backend/uploads/avatars/preset_03.png new file mode 100644 index 0000000..3256ee7 Binary files /dev/null and b/backend/uploads/avatars/preset_03.png differ diff --git a/backend/uploads/avatars/preset_04.png b/backend/uploads/avatars/preset_04.png new file mode 100644 index 0000000..d06ae1b Binary files /dev/null and b/backend/uploads/avatars/preset_04.png differ diff --git a/backend/uploads/avatars/preset_05.png b/backend/uploads/avatars/preset_05.png new file mode 100644 index 0000000..21cf08e Binary files /dev/null and b/backend/uploads/avatars/preset_05.png differ diff --git a/backend/uploads/avatars/preset_06.png b/backend/uploads/avatars/preset_06.png new file mode 100644 index 0000000..1afb376 Binary files /dev/null and b/backend/uploads/avatars/preset_06.png differ diff --git a/backend/uploads/avatars/preset_07.png b/backend/uploads/avatars/preset_07.png new file mode 100644 index 0000000..c6b5eb6 Binary files /dev/null and b/backend/uploads/avatars/preset_07.png differ diff --git a/backend/uploads/avatars/preset_08.png b/backend/uploads/avatars/preset_08.png new file mode 100644 index 0000000..1231df5 Binary files /dev/null and b/backend/uploads/avatars/preset_08.png differ diff --git a/backend/uploads/avatars/preset_09.png b/backend/uploads/avatars/preset_09.png new file mode 100644 index 0000000..121c04e Binary files /dev/null and b/backend/uploads/avatars/preset_09.png differ diff --git a/backend/uploads/avatars/preset_10.png b/backend/uploads/avatars/preset_10.png new file mode 100644 index 0000000..587d50c Binary files /dev/null and b/backend/uploads/avatars/preset_10.png differ diff --git a/backend/uploads/avatars/preset_11.png b/backend/uploads/avatars/preset_11.png new file mode 100644 index 0000000..30fde7f Binary files /dev/null and b/backend/uploads/avatars/preset_11.png differ diff --git a/backend/uploads/avatars/preset_12.png b/backend/uploads/avatars/preset_12.png new file mode 100644 index 0000000..35a1e60 Binary files /dev/null and b/backend/uploads/avatars/preset_12.png differ diff --git a/backend/uploads/avatars/preset_13.png b/backend/uploads/avatars/preset_13.png new file mode 100644 index 0000000..d7f89a4 Binary files /dev/null and b/backend/uploads/avatars/preset_13.png differ diff --git a/backend/uploads/avatars/preset_14.png b/backend/uploads/avatars/preset_14.png new file mode 100644 index 0000000..607b130 Binary files /dev/null and b/backend/uploads/avatars/preset_14.png differ diff --git a/backend/uploads/avatars/preset_15.png b/backend/uploads/avatars/preset_15.png new file mode 100644 index 0000000..37bda15 Binary files /dev/null and b/backend/uploads/avatars/preset_15.png differ diff --git a/backend/uploads/avatars/preset_16.png b/backend/uploads/avatars/preset_16.png new file mode 100644 index 0000000..f2fea57 Binary files /dev/null and b/backend/uploads/avatars/preset_16.png differ diff --git a/backend/uploads/avatars/preset_17.png b/backend/uploads/avatars/preset_17.png new file mode 100644 index 0000000..fe55a08 Binary files /dev/null and b/backend/uploads/avatars/preset_17.png differ diff --git a/backend/uploads/avatars/preset_18.png b/backend/uploads/avatars/preset_18.png new file mode 100644 index 0000000..49ce310 Binary files /dev/null and b/backend/uploads/avatars/preset_18.png differ diff --git a/backend/uploads/avatars/preset_19.png b/backend/uploads/avatars/preset_19.png new file mode 100644 index 0000000..3c48c20 Binary files /dev/null and b/backend/uploads/avatars/preset_19.png differ diff --git a/backend/uploads/avatars/preset_20.png b/backend/uploads/avatars/preset_20.png new file mode 100644 index 0000000..94079f9 Binary files /dev/null and b/backend/uploads/avatars/preset_20.png differ diff --git a/backend/uploads/avatars/preset_21.png b/backend/uploads/avatars/preset_21.png new file mode 100644 index 0000000..0ff0e2d Binary files /dev/null and b/backend/uploads/avatars/preset_21.png differ diff --git a/backend/uploads/avatars/preset_22.png b/backend/uploads/avatars/preset_22.png new file mode 100644 index 0000000..9b77015 Binary files /dev/null and b/backend/uploads/avatars/preset_22.png differ diff --git a/backend/uploads/avatars/preset_23.png b/backend/uploads/avatars/preset_23.png new file mode 100644 index 0000000..3fa5162 Binary files /dev/null and b/backend/uploads/avatars/preset_23.png differ diff --git a/backend/uploads/avatars/preset_24.png b/backend/uploads/avatars/preset_24.png new file mode 100644 index 0000000..ffc9f3d Binary files /dev/null and b/backend/uploads/avatars/preset_24.png differ diff --git a/backend/uploads/avatars/preset_25.png b/backend/uploads/avatars/preset_25.png new file mode 100644 index 0000000..55beeb1 Binary files /dev/null and b/backend/uploads/avatars/preset_25.png differ diff --git a/backend/uploads/avatars/preset_26.png b/backend/uploads/avatars/preset_26.png new file mode 100644 index 0000000..abecf2a Binary files /dev/null and b/backend/uploads/avatars/preset_26.png differ diff --git a/backend/uploads/avatars/preset_27.png b/backend/uploads/avatars/preset_27.png new file mode 100644 index 0000000..fe3ec61 Binary files /dev/null and b/backend/uploads/avatars/preset_27.png differ diff --git a/frontend/profile.html b/frontend/profile.html index 6fca36d..84da709 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -718,6 +718,48 @@ } .av-btn-send:hover { opacity: 0.88; } .av-btn-send:disabled { opacity: 0.45; cursor: not-allowed; } + /* Preset gallery */ + .av-preset-hd { + width: 100%; display: flex; align-items: center; gap: 10px; + font-size: 0.74rem; font-weight: 700; color: rgba(255,255,255,0.55); + letter-spacing: 0.04em; text-transform: uppercase; + } + .av-preset-hd::before, .av-preset-hd::after { + content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.08); + } + .av-preset-grid { + width: 100%; + display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; + max-height: 220px; overflow-y: auto; + padding: 4px; box-sizing: border-box; + scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.4) transparent; + } + .av-preset-grid::-webkit-scrollbar { width: 6px; } + .av-preset-grid::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.4); border-radius: 99px; } + .av-preset { + aspect-ratio: 1/1; border-radius: 12px; overflow: hidden; + border: 2px solid transparent; cursor: pointer; + background: rgba(255,255,255,0.04); + transition: transform .15s, border-color .15s, box-shadow .15s; + padding: 0; + } + .av-preset img { width: 100%; height: 100%; object-fit: cover; display: block; } + .av-preset:hover { + transform: scale(1.06); border-color: rgba(155,93,229,0.5); + box-shadow: 0 4px 14px rgba(155,93,229,0.25); + } + .av-preset.active { + border-color: var(--violet); + box-shadow: 0 0 0 2px rgba(155,93,229,0.3), 0 4px 14px rgba(155,93,229,0.4); + } + .av-or { + width: 100%; display: flex; align-items: center; gap: 10px; + font-size: 0.7rem; font-weight: 600; color: rgba(255,255,255,0.35); + letter-spacing: 0.04em; + } + .av-or::before, .av-or::after { + content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.06); + } @@ -1228,11 +1270,11 @@ avatarEl.textContent = initials; } - // Show edit button for students only - if (u.role === 'student') { - document.getElementById('p-avatar-edit-btn').style.display = 'flex'; - loadAvatarStatus(); - } + // Show edit button for all roles + document.getElementById('p-avatar-edit-btn').style.display = 'flex'; + // Upload-with-moderation only for students; admin/teacher use presets directly + window._lsRole = u.role; + if (u.role === 'student') loadAvatarStatus(); document.getElementById('profile-name').textContent = u.name||'—'; document.getElementById('profile-email').textContent= u.email||'—'; @@ -1864,11 +1906,72 @@ // Show delete btn only if has actual avatar document.getElementById('av-del-btn').classList.toggle('visible', !!img); - avLoadStatus(); + // Show upload section only for students (moderation) + document.getElementById('av-upload-block').style.display = + (window._lsRole === 'student') ? 'flex' : 'none'; + + avLoadPresets(); + if (window._lsRole === 'student') avLoadStatus(); modal.classList.add('open'); if (LS.sfx) LS.sfx.play('modal_open'); } + /* ── Load preset grid ── */ + let _avPresetsCache = null; + async function avLoadPresets() { + const grid = document.getElementById('av-preset-grid'); + if (!_avPresetsCache) { + try { + const data = await LS.get('/api/avatar/presets'); + _avPresetsCache = data.presets || []; + } catch { _avPresetsCache = []; } + } + // Find current avatar filename to mark as active + const bigAv = document.getElementById('big-avatar'); + const img = bigAv.querySelector('img'); + const curSrc = img ? img.src : ''; + const curFile = curSrc.split('/').pop().split('?')[0]; + + grid.innerHTML = _avPresetsCache.map(f => ` + + `).join(''); + } + + /* ── Pick preset ── */ + async function avPickPreset(filename, btn) { + if (btn.disabled) return; + btn.disabled = true; + try { + await LS.post('/api/avatar/preset', { filename }); + const url = '/avatars/' + filename + '?t=' + Date.now(); + + // Update left-panel avatar + document.getElementById('big-avatar').innerHTML = + `Аватар`; + // Reset student status badge + document.getElementById('p-avatar-status').className = 'p-avatar-status'; + // Update modal preview + document.getElementById('av-modal-cur').innerHTML = + `Аватар`; + document.getElementById('av-modal-status').className = 'av-status-row'; + document.getElementById('av-del-btn').classList.add('visible'); + + // Mark active + document.querySelectorAll('.av-preset').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + LS.toast('Аватар обновлён', 'success'); + if (LS.sfx) LS.sfx.play('success'); + } catch (e) { + LS.toast(e.message || 'Ошибка', 'error'); + } finally { + btn.disabled = false; + } + } + function avClose() { document.getElementById('av-modal').classList.remove('open'); avBack(); @@ -2180,20 +2283,27 @@
- -
- - - - - -
Перетащите фото или нажмите для выбора
-
PNG, JPG, WebP · до 2 МБ
- + +
Готовые аватары
+
+ + +