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 @@