feat(avatars): 27 готовых пресет-аватаров + UI выбора для всех ролей

- backend/uploads/avatars/preset_01..27.png — иллюстрированные персонажи
- POST /api/avatar/preset — мгновенная установка без модерации
- GET  /api/avatar/presets — список доступных пресетов
- profile.html: галерея пресетов в модалке аватара, доступна студенту/учителю/админу
- кастомная загрузка с модерацией остаётся только для студентов
This commit is contained in:
Maxim Dolgolyov
2026-05-29 14:30:24 +03:00
parent 717ad3d0f5
commit 19ce8728e5
30 changed files with 175 additions and 22 deletions
+41 -2
View File
@@ -98,10 +98,49 @@ function rejectAvatar(req, res) {
function removeAvatar(req, res) { function removeAvatar(req, res) {
const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id); const user = db.prepare('SELECT avatar_url FROM users WHERE id=?').get(req.user.id);
if (user?.avatar_url) { 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); db.prepare('UPDATE users SET avatar_url=NULL WHERE id=?').run(req.user.id);
} }
res.json({ ok: true }); 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 };
+4
View File
@@ -32,6 +32,10 @@ router.post('/request', authMiddleware, upload.single('avatar'), ctrl.requestA
router.get('/my-status', authMiddleware, ctrl.myStatus); router.get('/my-status', authMiddleware, ctrl.myStatus);
router.delete('/me', authMiddleware, ctrl.removeAvatar); 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) ────────────────────────────────── */ /* ── moderator routes (teacher or admin) ────────────────────────────────── */
router.get('/pending', authMiddleware, requireRole('teacher', 'admin'), ctrl.getPending); router.get('/pending', authMiddleware, requireRole('teacher', 'admin'), ctrl.getPending);
router.post('/:id/approve', authMiddleware, requireRole('teacher', 'admin'), ctrl.approveAvatar); router.post('/:id/approve', authMiddleware, requireRole('teacher', 'admin'), ctrl.approveAvatar);
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

+130 -20
View File
@@ -718,6 +718,48 @@
} }
.av-btn-send:hover { opacity: 0.88; } .av-btn-send:hover { opacity: 0.88; }
.av-btn-send:disabled { opacity: 0.45; cursor: not-allowed; } .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);
}
</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>
@@ -1228,11 +1270,11 @@
avatarEl.textContent = initials; avatarEl.textContent = initials;
} }
// Show edit button for students only // Show edit button for all roles
if (u.role === 'student') { document.getElementById('p-avatar-edit-btn').style.display = 'flex';
document.getElementById('p-avatar-edit-btn').style.display = 'flex'; // Upload-with-moderation only for students; admin/teacher use presets directly
loadAvatarStatus(); window._lsRole = u.role;
} if (u.role === 'student') loadAvatarStatus();
document.getElementById('profile-name').textContent = u.name||'—'; document.getElementById('profile-name').textContent = u.name||'—';
document.getElementById('profile-email').textContent= u.email||'—'; document.getElementById('profile-email').textContent= u.email||'—';
@@ -1864,11 +1906,72 @@
// Show delete btn only if has actual avatar // Show delete btn only if has actual avatar
document.getElementById('av-del-btn').classList.toggle('visible', !!img); 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'); modal.classList.add('open');
if (LS.sfx) LS.sfx.play('modal_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 => `
<button class="av-preset${f === curFile ? ' active' : ''}" data-file="${LS.escapeHtml(f)}"
onclick="avPickPreset('${LS.escapeHtml(f)}', this)" title="Выбрать аватар">
<img src="/avatars/${LS.escapeHtml(f)}" alt="" loading="lazy">
</button>
`).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 =
`<img src="${url}" alt="Аватар">`;
// Reset student status badge
document.getElementById('p-avatar-status').className = 'p-avatar-status';
// Update modal preview
document.getElementById('av-modal-cur').innerHTML =
`<img src="${url}" alt="Аватар">`;
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() { function avClose() {
document.getElementById('av-modal').classList.remove('open'); document.getElementById('av-modal').classList.remove('open');
avBack(); avBack();
@@ -2180,20 +2283,27 @@
<!-- Status (pending / rejected) --> <!-- Status (pending / rejected) -->
<div class="av-status-row" id="av-modal-status"></div> <div class="av-status-row" id="av-modal-status"></div>
<!-- Drop zone --> <!-- Preset gallery -->
<div class="av-drop" id="av-drop" <div class="av-preset-hd">Готовые аватары</div>
ondragover="avDragOver(event)" ondragleave="avDragLeave(event)" ondrop="avDrop(event)" <div class="av-preset-grid" id="av-preset-grid"></div>
onclick="document.getElementById('av-file-inp').click()" role="button" tabindex="0"
onkeydown="if(event.key==='Enter'||event.key===' ')this.click()"> <!-- Custom upload (student only) -->
<svg class="av-drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <div id="av-upload-block" style="width:100%;display:none;flex-direction:column;align-items:center;gap:14px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <div class="av-or">или загрузите своё</div>
<polyline points="17 8 12 3 7 8"/> <div class="av-drop" id="av-drop"
<line x1="12" y1="3" x2="12" y2="15"/> ondragover="avDragOver(event)" ondragleave="avDragLeave(event)" ondrop="avDrop(event)"
</svg> onclick="document.getElementById('av-file-inp').click()" role="button" tabindex="0"
<div class="av-drop-txt">Перетащите фото или нажмите для выбора</div> onkeydown="if(event.key==='Enter'||event.key===' ')this.click()">
<div class="av-drop-sub">PNG, JPG, WebP · до 2 МБ</div> <svg class="av-drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<input type="file" id="av-file-inp" accept="image/png,image/jpeg,image/webp" <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
style="display:none" onchange="avFileChosen(this)"> <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>
</div> </div>
<!-- Delete current avatar --> <!-- Delete current avatar -->