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
+130 -20
View File
@@ -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 -->