feat(avatars): 27 готовых пресет-аватаров + UI выбора для всех ролей
- backend/uploads/avatars/preset_01..27.png — иллюстрированные персонажи - POST /api/avatar/preset — мгновенная установка без модерации - GET /api/avatar/presets — список доступных пресетов - profile.html: галерея пресетов в модалке аватара, доступна студенту/учителю/админу - кастомная загрузка с модерацией остаётся только для студентов
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 161 KiB |
@@ -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);
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
</head>
|
||||
@@ -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 => `
|
||||
<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() {
|
||||
document.getElementById('av-modal').classList.remove('open');
|
||||
avBack();
|
||||
@@ -2180,20 +2283,27 @@
|
||||
<!-- Status (pending / rejected) -->
|
||||
<div class="av-status-row" id="av-modal-status"></div>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div class="av-drop" id="av-drop"
|
||||
ondragover="avDragOver(event)" ondragleave="avDragLeave(event)" ondrop="avDrop(event)"
|
||||
onclick="document.getElementById('av-file-inp').click()" role="button" tabindex="0"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' ')this.click()">
|
||||
<svg class="av-drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<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)">
|
||||
<!-- Preset gallery -->
|
||||
<div class="av-preset-hd">Готовые аватары</div>
|
||||
<div class="av-preset-grid" id="av-preset-grid"></div>
|
||||
|
||||
<!-- Custom upload (student only) -->
|
||||
<div id="av-upload-block" style="width:100%;display:none;flex-direction:column;align-items:center;gap:14px">
|
||||
<div class="av-or">или загрузите своё</div>
|
||||
<div class="av-drop" id="av-drop"
|
||||
ondragover="avDragOver(event)" ondragleave="avDragLeave(event)" ondrop="avDrop(event)"
|
||||
onclick="document.getElementById('av-file-inp').click()" role="button" tabindex="0"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' ')this.click()">
|
||||
<svg class="av-drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<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>
|
||||
|
||||
<!-- Delete current avatar -->
|
||||
|
||||