feat: avatar moderation — ученик загружает фото, учитель/админ подтверждает или отклоняет

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 20:55:45 +03:00
parent 6429e07606
commit c2eb319162
8 changed files with 422 additions and 22 deletions
+97 -2
View File
@@ -75,7 +75,26 @@
background: linear-gradient(135deg, rgba(155,93,229,0.6), rgba(6,214,224,0.4));
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: #fff;
overflow: hidden;
}
.p-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.p-avatar-edit-btn {
position: absolute; bottom: 0; right: 0;
width: 26px; height: 26px; border-radius: 50%;
background: #9B5DE5; border: 2px solid #1a1248;
display: flex; align-items: center; justify-content: center;
cursor: pointer; z-index: 2; transition: background .15s;
}
.p-avatar-edit-btn:hover { background: #7c3fd4; }
.p-avatar-edit-btn svg { width: 13px; height: 13px; color: #fff; }
.p-avatar-status {
margin-top: 8px; font-size: 0.68rem; font-weight: 600;
padding: 3px 10px; border-radius: 99px;
display: none;
}
.p-avatar-status.pending { display: inline-block; background: rgba(255,200,0,0.18); color: #ffd166; }
.p-avatar-status.rejected { display: inline-block; background: rgba(241,91,181,0.18); color: #F15BB5; }
.p-avatar-status.approved { display: inline-block; background: rgba(6,214,96,0.18); color: #06d660; }
.p-name {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
@@ -592,10 +611,21 @@
<div class="p-left-inner">
<!-- Avatar -->
<div class="p-avatar-wrap">
<div class="p-avatar-wrap" id="p-avatar-wrap">
<div class="p-avatar-ring"><div class="p-avatar-ring-inner"></div></div>
<div class="p-avatar" id="big-avatar">LS</div>
<!-- Edit button — shown only for students via JS -->
<button class="p-avatar-edit-btn" id="p-avatar-edit-btn" title="Изменить аватар" style="display:none"
onclick="document.getElementById('p-avatar-input').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<input type="file" id="p-avatar-input" accept="image/png,image/jpeg,image/webp"
style="display:none" onchange="avatarFileSelected(this)">
</div>
<div class="p-avatar-status" id="p-avatar-status"></div>
<div class="p-name" id="profile-name"></div>
<div class="p-email" id="profile-email"></div>
@@ -1063,7 +1093,21 @@
try {
const u = await LS.fetchMe();
const initials = (u.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
document.getElementById('big-avatar').textContent = initials;
// Avatar: photo or initials
const avatarEl = document.getElementById('big-avatar');
if (u.avatar_url) {
avatarEl.innerHTML = `<img src="/avatars/${LS.escapeHtml(u.avatar_url)}" alt="Аватар">`;
} else {
avatarEl.textContent = initials;
}
// Show edit button for students only
if (u.role === 'student') {
document.getElementById('p-avatar-edit-btn').style.display = 'flex';
loadAvatarStatus();
}
document.getElementById('profile-name').textContent = u.name||'—';
document.getElementById('profile-email').textContent= u.email||'—';
document.getElementById('profile-role').textContent = ROLE_LABELS[u.role]||u.role;
@@ -1085,6 +1129,57 @@
} catch {}
}
/* ── Avatar upload ────────────────────────────────────────────────────── */
async function loadAvatarStatus() {
try {
const data = await LS.get('/api/avatar/my-status');
const el = document.getElementById('p-avatar-status');
if (!data.request) { el.className = 'p-avatar-status'; return; }
const r = data.request;
if (r.status === 'pending') {
el.className = 'p-avatar-status pending';
el.textContent = 'На проверке';
} else if (r.status === 'rejected') {
el.className = 'p-avatar-status rejected';
el.textContent = r.reject_msg ? 'Отклонено: ' + r.reject_msg : 'Отклонено';
} else if (r.status === 'approved') {
el.className = 'p-avatar-status approved';
el.textContent = 'Одобрено';
// Auto-hide after 3s
setTimeout(() => { el.className = 'p-avatar-status'; }, 3000);
}
} catch {}
}
async function avatarFileSelected(input) {
const file = input.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) { LS.toast('Файл слишком большой (макс. 2 МБ)', 'error'); return; }
const fd = new FormData();
fd.append('avatar', file);
try {
const token = LS.getToken();
const res = await fetch('/api/avatar/request', {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: fd,
});
if (!res.ok) throw new Error();
LS.toast('Аватар отправлен на проверку', 'success');
// Show pending preview locally
const url = URL.createObjectURL(file);
const wrap = document.getElementById('big-avatar');
wrap.innerHTML = `<img src="${url}" alt="Аватар" style="opacity:.6">`;
document.getElementById('p-avatar-status').className = 'p-avatar-status pending';
document.getElementById('p-avatar-status').textContent = 'На проверке';
} catch {
LS.toast('Ошибка загрузки', 'error');
}
input.value = '';
}
/* ── Student ── */
async function loadStudentStats() {
try {