feat: avatar moderation — ученик загружает фото, учитель/админ подтверждает или отклоняет
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -827,6 +827,39 @@
|
||||
.cr-hist-search { flex: 1; min-width: 180px; padding: 9px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family:'Manrope',sans-serif; font-size:0.88rem; background:var(--surface); color:var(--text); }
|
||||
.cr-hist-search:focus { outline:none; border-color:var(--violet); }
|
||||
.cr-hist-count { font-size:0.85rem; color:var(--text-3); font-weight:600; white-space:nowrap; }
|
||||
/* ── Avatar moderation ── */
|
||||
.av-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px,1fr)); gap: 16px; }
|
||||
.av-card {
|
||||
background: var(--surface); border: 1.5px solid var(--border-h);
|
||||
border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.av-card-top { display: flex; align-items: center; gap: 12px; }
|
||||
.av-imgs { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
|
||||
.av-img-wrap { text-align: center; }
|
||||
.av-img-wrap span { font-size: 0.62rem; color: var(--text-3); font-weight: 600; display: block; margin-bottom: 4px; }
|
||||
.av-img {
|
||||
width: 64px; height: 64px; border-radius: 50%; object-fit: cover;
|
||||
border: 2px solid var(--border-h); background: var(--surface-2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
|
||||
color: var(--text-2); overflow: hidden;
|
||||
}
|
||||
.av-img img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.av-arrow { color: var(--text-3); flex-shrink: 0; }
|
||||
.av-user-name { font-size: 0.82rem; font-weight: 700; color: var(--text); }
|
||||
.av-date { font-size: 0.7rem; color: var(--text-3); margin-top: 2px; }
|
||||
.av-actions { display: flex; gap: 8px; }
|
||||
.av-approve { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 700; background: rgba(6,214,96,0.12); color: #06d660; transition: background .15s; }
|
||||
.av-approve:hover { background: rgba(6,214,96,0.22); }
|
||||
.av-reject { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 700; background: rgba(241,91,181,0.12); color: #F15BB5; transition: background .15s; }
|
||||
.av-reject:hover { background: rgba(241,91,181,0.22); }
|
||||
.av-empty { text-align: center; padding: 60px 0; color: var(--text-3); font-size: 0.85rem; }
|
||||
.admin-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 18px; height: 18px; border-radius: 99px; padding: 0 5px;
|
||||
background: #F15BB5; color: #fff; font-size: 0.65rem; font-weight: 800;
|
||||
margin-left: 4px; vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -878,6 +911,10 @@
|
||||
<button class="admin-nav-item" data-tab="permissions" onclick="switchTab(this)" id="btn-tab-permissions" style="display:none">
|
||||
<i data-lucide="shield" style="width:15px;height:15px"></i> Права доступа
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="avatars" onclick="switchTab(this);loadAvatarRequests()">
|
||||
<i data-lucide="image" style="width:15px;height:15px"></i> Аватары
|
||||
<span class="admin-badge" id="av-badge" style="display:none"></span>
|
||||
</button>
|
||||
|
||||
<div class="admin-nav-sep" id="admin-nav-system-sep" style="display:none"></div>
|
||||
<div class="admin-nav-label" id="admin-nav-system-label" style="display:none">Система</div>
|
||||
@@ -1102,6 +1139,19 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Права доступа ── -->
|
||||
<!-- ── Avatars moderation tab ── -->
|
||||
<div class="tab-pane" id="tab-avatars">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px">
|
||||
<div class="section-title" style="margin:0">Аватары на модерации</div>
|
||||
<button class="btn-outline" onclick="loadAvatarRequests()" style="padding:6px 14px;font-size:0.8rem">
|
||||
<i data-lucide="refresh-cw" style="width:13px;height:13px;vertical-align:-2px"></i> Обновить
|
||||
</button>
|
||||
</div>
|
||||
<div id="av-list">
|
||||
<div style="color:var(--muted);text-align:center;padding:40px 0;font-size:0.85rem">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" id="tab-permissions">
|
||||
<div class="perm-header">
|
||||
<div class="section-title" style="margin:0">Права доступа по ролям</div>
|
||||
@@ -5220,10 +5270,93 @@
|
||||
else if (tab === 'errors') loadErrorLog();
|
||||
else if (tab === 'health') loadHealth();
|
||||
else if (tab === 'classroom') { loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
|
||||
else if (tab === 'avatars') { loadAvatarRequests(); }
|
||||
};
|
||||
|
||||
/* ── Avatar moderation ─────────────────────────────────────────────── */
|
||||
async function loadAvatarRequests() {
|
||||
const list = document.getElementById('av-list');
|
||||
list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:40px 0;font-size:0.85rem">Загрузка...</div>';
|
||||
try {
|
||||
const rows = await LS.get('/api/avatar/pending');
|
||||
const badge = document.getElementById('av-badge');
|
||||
if (rows.length) {
|
||||
badge.textContent = rows.length;
|
||||
badge.style.display = 'inline-flex';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
if (!rows.length) {
|
||||
list.innerHTML = '<div class="av-empty"><i data-lucide="check-circle" style="width:36px;height:36px;opacity:.3;display:block;margin:0 auto 10px"></i>Нет заявок на модерацию</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
list.innerHTML = `<div class="av-grid">${rows.map(r => {
|
||||
const initials = (r.user_name||'LS').split(' ').slice(0,2).map(w=>(w[0]||'').toUpperCase()).join('') || 'LS';
|
||||
const curAvatar = r.current_avatar
|
||||
? `<img src="/avatars/${esc(r.current_avatar)}" alt="">`
|
||||
: initials;
|
||||
const newAvatar = `<img src="/avatars/${esc(r.filename)}" alt="" onerror="this.parentElement.textContent='?'">`;
|
||||
const d = new Date(r.created_at).toLocaleString('ru', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' });
|
||||
return `<div class="av-card" id="av-card-${r.id}">
|
||||
<div class="av-card-top">
|
||||
<div class="av-imgs">
|
||||
<div class="av-img-wrap">
|
||||
<span>Сейчас</span>
|
||||
<div class="av-img">${curAvatar}</div>
|
||||
</div>
|
||||
<svg class="av-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
<div class="av-img-wrap">
|
||||
<span>Новый</span>
|
||||
<div class="av-img">${newAvatar}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="av-user-name">${esc(r.user_name||r.user_email)}</div>
|
||||
<div class="av-date">${esc(r.user_email)} · ${d}</div>
|
||||
</div>
|
||||
<div class="av-actions">
|
||||
<button class="av-approve" onclick="avatarApprove(${r.id})">Одобрить</button>
|
||||
<button class="av-reject" onclick="avatarRejectPrompt(${r.id})">Отклонить</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}</div>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
} catch {
|
||||
list.innerHTML = '<div class="av-empty">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function avatarApprove(id) {
|
||||
const card = document.getElementById('av-card-' + id);
|
||||
if (card) card.style.opacity = '0.5';
|
||||
try {
|
||||
await LS.post('/api/avatar/' + id + '/approve', {});
|
||||
LS.toast('Аватар одобрен', 'success');
|
||||
loadAvatarRequests();
|
||||
} catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; }
|
||||
}
|
||||
|
||||
function avatarRejectPrompt(id) {
|
||||
const reason = prompt('Причина отклонения (необязательно):') ?? null;
|
||||
if (reason === null) return; // cancelled
|
||||
avatarReject(id, reason);
|
||||
}
|
||||
|
||||
async function avatarReject(id, reason) {
|
||||
const card = document.getElementById('av-card-' + id);
|
||||
if (card) card.style.opacity = '0.5';
|
||||
try {
|
||||
await LS.patch('/api/avatar/' + id + '/reject', { reason });
|
||||
LS.toast('Аватар отклонён', 'info');
|
||||
loadAvatarRequests();
|
||||
} catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; }
|
||||
}
|
||||
|
||||
/* ─── init ─── */
|
||||
loadStats();
|
||||
loadAvatarRequests(); // load badge count on page open
|
||||
if (window.lucide) lucide.createIcons();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user