'use strict'; // admin.html — main orchestrator (thin shell after Phase 2 section split). // Section modules live in /js/admin/sections/*.js — admin.js wires them // to the router + handles tabs not yet extracted (topics/audit/errors/health/classroom/avatars). // Order of operation preserved: loads after api.js + sidebar.js + router.js + _shared.js + sections/*.js const { user, isTeacher, isAdmin } = LS.initPage(); if (!isTeacher) { window.location.href = '/dashboard'; throw new Error(); } document.getElementById('page-sub').textContent = isAdmin ? 'Администратор · полный доступ' : 'Учитель · просмотр статистики'; /* Populate shared context for section modules */ AdminCtx.user = user; AdminCtx.isTeacher = isTeacher; AdminCtx.isAdmin = isAdmin; /* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */ const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games']; const lockSvg = ''; ADMIN_ONLY_TABS.forEach(id => { const el = document.getElementById(id); if (!el) return; el.style.display = ''; // always visible now if (!isAdmin) { el.classList.add('locked'); el.title = 'Только для администраторов'; el.insertAdjacentHTML('beforeend', lockSvg); } }); // Система group: visible to everyone too const sysGroup = document.getElementById('admin-nav-system-group'); if (sysGroup) sysGroup.style.display = ''; /* Collapsible nav groups — state persisted in localStorage */ window.toggleAdminGroup = function (slug) { const g = document.querySelector(`.admin-nav-group[data-ng="${slug}"]`); if (!g) return; const collapsed = g.classList.toggle('collapsed'); try { localStorage.setItem('ls_adm_g_' + slug, collapsed ? '1' : '0'); } catch {} }; // Restore collapsed state on page load document.querySelectorAll('.admin-nav-group').forEach(g => { const slug = g.dataset.ng; try { if (localStorage.getItem('ls_adm_g_' + slug) === '1') g.classList.add('collapsed'); } catch {} }); LS.showBoardIfAllowed(); LS.hideDisabledFeatures?.(); LS.notif?.init(); /* ─── Tabs → section bridge ─── */ // Routes that map 1:1 to a section module (Phase 2-extracted). const ROUTE_TO_SECTION = { stats: 'stats', questions: 'questions', tests: 'tests', assignments: 'assignments', subjects: 'subjects', users: 'users', sessions: 'sessions', permissions: 'permissions', shop: 'shop', gam: 'gam', tpl: 'tpl', sims: 'sims', games: 'games', sublog: 'sublog', }; function switchTab(btn, opts) { if (btn.classList.contains('locked')) { LS.toast('Этот раздел доступен только администраторам', 'warn'); return; } document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active')); const name = btn.dataset.tab; document.getElementById('tab-' + name).classList.add('active'); btn.classList.add('active'); // Dispatch to section module for the 13 Phase-2 tabs const secName = ROUTE_TO_SECTION[name]; if (secName && AdminSections[secName]) { AdminSections[secName].init(); } // System tabs (not yet extracted in Phase 2) — load inline if (name === 'topics') loadTopics(); else if (name === 'audit') loadAuditLog(); else if (name === 'errors') loadErrorLog(); else if (name === 'health') loadHealth(); else if (name === 'classroom'){ loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); } else if (name === 'avatars') loadAvatarRequests(); // Sync URL hash via router (silent so we don't recurse). if (!(opts && opts.fromRouter) && window.AdminRouter) { AdminRouter.navigate('#' + name, { silent: true }); } } window.switchTab = switchTab; /* Cross-section orchestrator: navigate to Questions tab + open Q-modal pre-filled */ async function goAddQuestion(slug) { const qBtn = document.querySelector('[data-tab="questions"]'); switchTab(qBtn); document.getElementById('q-subject').value = slug; await AdminSections.questions.reload(); AdminSections.questions.openModal(); document.getElementById('qf-subject').value = slug; await AdminSections.questions.loadModalTopics(); } window.goAddQuestion = goAddQuestion; /* ═══ TOPICS ═══════════════════════════════════════════════════════ */ let _topicsSubjects = []; async function loadTopicSubjects() { if (_topicsSubjects.length) return; try { _topicsSubjects = await LS.getSubjects(); const sel = document.getElementById('topics-subj-filter'); sel.innerHTML = _topicsSubjects.map(s => ``).join(''); } catch {} } async function loadTopics() { await loadTopicSubjects(); const subjId = document.getElementById('topics-subj-filter').value; const el = document.getElementById('topics-list'); el.innerHTML = LS.skeleton(4, 'row'); try { const rows = await LS.api(`/api/admin/topics?subject_id=${subjId}`); document.getElementById('topics-count').textContent = rows.length + ' тем'; if (!rows.length) { el.innerHTML = '
Тем нет
'; return; } el.innerHTML = '
' + rows.map(t => `
#${t.order_index} ${esc(t.name)} ${t.question_count} вопр.
`).join('') + '
'; if (window.lucide) lucide.createIcons({ nodes: [el] }); } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } } function showAddTopic() { document.getElementById('topics-add-row').style.display = ''; document.getElementById('topics-new-name').focus(); } async function createTopic() { const name = document.getElementById('topics-new-name').value.trim(); if (!name) return; const subjId = document.getElementById('topics-subj-filter').value; try { await LS.api('/api/admin/topics', { method:'POST', body: JSON.stringify({ subject_id: subjId, name }) }); document.getElementById('topics-new-name').value = ''; document.getElementById('topics-add-row').style.display = 'none'; LS.toast('Тема создана', 'success'); loadTopics(); } catch (e) { LS.toast(e.message, 'error'); } } async function renameTopic(id, oldName) { const name = prompt('Новое название темы:', oldName); if (!name || name === oldName) return; try { await LS.api(`/api/admin/topics/${id}`, { method:'PATCH', body: JSON.stringify({ name }) }); LS.toast('Тема переименована', 'success'); loadTopics(); } catch (e) { LS.toast(e.message, 'error'); } } async function deleteTopic(id, name, qcount) { if (qcount > 0) { LS.toast(`Нельзя удалить тему с ${qcount} вопросами`, 'warn'); return; } if (!await LS.confirm(`Удалить тему "${name}"?`, { danger: true })) return; try { await LS.api(`/api/admin/topics/${id}`, { method:'DELETE' }); LS.toast('Тема удалена', 'success'); loadTopics(); } catch (e) { LS.toast(e.message, 'error'); } } window.showAddTopic = showAddTopic; window.createTopic = createTopic; window.renameTopic = renameTopic; window.deleteTopic = deleteTopic; /* ═══ BROADCAST ═════════════════════════════════════════════════════ */ async function sendBroadcast() { const message = document.getElementById('bc-message').value.trim(); if (!message) { LS.toast('Введите сообщение', 'warn'); return; } const role = document.getElementById('bc-role').value; const link = document.getElementById('bc-link').value.trim() || null; try { const r = await LS.api('/api/admin/broadcast', { method:'POST', body: JSON.stringify({ message, role, link }) }); document.getElementById('bc-result').textContent = `Отправлено ${r.sent} пользователям`; document.getElementById('bc-message').value = ''; LS.toast(`Уведомление отправлено ${r.sent} пользователям`, 'success'); } catch (e) { LS.toast(e.message, 'error'); } } window.sendBroadcast = sendBroadcast; /* ═══ AUDIT LOG ════════════════════════════════════════════════════ */ async function loadAuditLog() { const el = document.getElementById('audit-list'); el.innerHTML = LS.skeleton(5, 'row'); try { const rows = await LS.api('/api/admin/audit-log?limit=200'); if (!rows.length) { el.innerHTML = '
Журнал пуст
'; return; } const ACTION_LABELS = { 'user.role_change': 'Смена роли', 'user.edit': 'Редактирование', 'user.ban': 'Блокировка', 'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории', 'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', 'topic.update': 'Редакт. темы', 'topic.delete': 'Удаление темы', 'broadcast': 'Рассылка', }; const ACTION_COLORS = { 'user.delete': 'var(--pink)', 'user.ban': 'var(--pink)', 'user.clear_sessions': 'var(--amber)', }; el.innerHTML = `
${rows.map(r => { const dt = new Date(r.created_at); const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}); const acol = ACTION_COLORS[r.action] || 'var(--violet)'; return ``; }).join('')}
ДатаАдминДействиеЦельДеталиIP
${ds} ${esc(r.admin_name || '—')} ${ACTION_LABELS[r.action] || r.action} ${esc(r.target || '')} ${esc(r.detail || '')} ${esc(r.ip || '')}
`; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } } async function clearAuditLog() { if (!await LS.confirm('Очистить весь аудит-лог?', { danger: true })) return; try { await LS.api('/api/admin/audit-log', { method:'DELETE' }); document.getElementById('audit-list').innerHTML = '
Журнал очищен
'; LS.toast('Журнал очищен', 'success'); } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } window.clearAuditLog = clearAuditLog; /* ═══ ERROR LOG ════════════════════════════════════════════════════ */ async function loadErrorLog() { const el = document.getElementById('errors-list'); el.innerHTML = LS.skeleton(3, 'row'); try { const rows = await LS.api('/api/admin/error-log?limit=200'); if (!rows.length) { el.innerHTML = '
Ошибок нет
'; return; } el.innerHTML = rows.map(r => { const dt = new Date(r.created_at); const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}); return `
${r.method || ''} ${esc(r.route || '')} ${ds} ${r.user_id ? `user:${r.user_id}` : ''}
${esc(r.message)}
${r.stack ? `
Stack trace
${esc(r.stack)}
` : ''}
`; }).join(''); } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } } async function clearErrorLog() { if (!await LS.confirm('Очистить журнал ошибок?', { danger: true })) return; try { await LS.api('/api/admin/error-log', { method:'DELETE' }); document.getElementById('errors-list').innerHTML = '
Журнал очищен
'; LS.toast('Журнал очищен', 'success'); } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } window.clearErrorLog = clearErrorLog; /* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */ async function loadHealth() { const el = document.getElementById('health-content'); el.innerHTML = LS.skeleton(3, 'row'); try { const h = await LS.api('/api/admin/health'); const fmtBytes = b => b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB'; const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}m`; }; el.innerHTML = `
${fmtUp(h.uptime)}
Uptime
${fmtBytes(h.db.sizeBytes)}
База данных
${fmtBytes(h.uploads.sizeBytes)}
Файлы
${h.recentErrors}
Ошибок за 24ч
Платформа
Node.js${h.node}
OS${h.platform}
CPU ядра${h.cpus}
RAM использовано${fmtBytes(h.memory.rss)}
RAM heap${fmtBytes(h.memory.heapUsed)}
RAM свободно${fmtBytes(h.freeMem)} / ${fmtBytes(h.totalMem)}
Данные
Пользователей${h.db.totalUsers}
Всего сессий${h.db.totalSessions}
Сессий сегодня${h.db.todaySessions}
Вопросов в базе${h.db.totalQuestions}
`; } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } } /* ════════════════════════════════════════════════ ОНЛАЙН-УРОКИ (classroom admin) ════════════════════════════════════════════════ */ let _crHistPage = 1, _crHistTotal = 0, _crHistPages = 0, _crHistSearch = ''; let _crOpenDetailId = null, _crHistDebTimer = null; async function loadCrModuleState() { try { const features = await LS.api('/api/admin/features'); const chk = document.getElementById('cr-master-chk'); if (chk) chk.checked = features.classroom !== false; } catch(e) { /* silent */ } } async function crMasterToggle(enabled) { try { await LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ classroom: enabled }) }); LS.toast(enabled ? 'Модуль онлайн-уроков включён' : 'Модуль онлайн-уроков отключён', enabled ? 'success' : 'warning', 3000); } catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); const chk = document.getElementById('cr-master-chk'); if (chk) chk.checked = !enabled; } } window.crMasterToggle = crMasterToggle; function fmtLiveDuration(createdAt) { const sec = Math.round((Date.now() - new Date(createdAt).getTime()) / 1000); return AdminCtx.fmtDuration(sec); } async function loadCrActiveSessions() { const el = document.getElementById('cr-live-list'); try { const { sessions } = await LS.api('/api/classroom/admin/active'); if (!sessions.length) { el.innerHTML = '
Нет активных уроков
'; return; } el.innerHTML = sessions.map(s => { const dur = fmtLiveDuration(s.created_at); const title = s.title || `Урок #${s.id}`; const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок'; return `
${esc(title)}
${esc(s.teacher_name)} · ${cls}
${s.online_count} ${s.message_count} ${dur}
`; }).join(''); } catch(e) { el.innerHTML = `
Ошибка: ${esc(e.message)}
`; } if (window.lucide) lucide.createIcons(); } async function adminEndSession(id) { if (!await LS.confirm(`Завершить урок #${id}? Все участники будут отключены.`, { title: 'Завершить урок', confirmText: 'Завершить' })) return; try { await LS.api(`/api/classroom/${id}`, { method: 'DELETE' }); LS.toast('Урок завершён', 'success', 2500); loadCrActiveSessions(); } catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); } } window.adminEndSession = adminEndSession; function crHistDebounce() { clearTimeout(_crHistDebTimer); _crHistDebTimer = setTimeout(() => { _crHistPage = 1; loadCrHistory(); }, 350); } window.crHistDebounce = crHistDebounce; async function loadCrHistory(page) { const { fmtDate, fmtDuration } = AdminCtx; if (page) _crHistPage = page; _crHistSearch = (document.getElementById('cr-hist-q')?.value || '').trim(); const el = document.getElementById('cr-hist-list'); el.innerHTML = '
'; try { const params = new URLSearchParams({ page: _crHistPage, limit: 20 }); if (_crHistSearch) params.set('search', _crHistSearch); const { sessions, total, pages } = await LS.api('/api/classroom/admin/sessions?' + params); _crHistTotal = total; _crHistPages = pages; document.getElementById('cr-hist-count').textContent = `${total} уроков`; if (!sessions.length) { el.innerHTML = '
Нет завершённых уроков
'; renderCrPagination(); return; } el.innerHTML = sessions.map(s => { const title = s.title || `Урок #${s.id}`; const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок'; const dur = fmtDuration(s.ended_at ? Math.round((new Date(s.ended_at)-new Date(s.created_at))/1000) : null); return `
${esc(title)}
${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}
${s.participant_count} уч. ${s.message_count} сообщ. ${dur}
`; }).join(''); if (_crOpenDetailId) { const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`); if (dr) loadCrSessionDetail(_crOpenDetailId); } renderCrPagination(); } catch(e) { el.innerHTML = `
Ошибка: ${esc(e.message)}
`; } if (window.lucide) lucide.createIcons(); } window.loadCrHistory = loadCrHistory; function renderCrPagination() { const el = document.getElementById('cr-hist-pagination'); if (_crHistPages <= 1) { el.innerHTML = ''; return; } const p = _crHistPage, total = _crHistPages; let html = '
'; html += ``; const range = []; for (let i=1;i<=total;i++) { if (i===1||i===total||Math.abs(i-p)<=1) range.push(i); else if (range[range.length-1]!=='…') range.push('…'); } range.forEach(r => { if (r==='…') html += ``; else html += ``; }); html += `
`; el.innerHTML = html; } async function toggleCrDetail(id, rowEl) { const wasOpen = _crOpenDetailId === id; document.querySelectorAll('.cr-hist-row.open').forEach(r => r.classList.remove('open')); document.querySelectorAll('.cr-detail-drawer.open').forEach(d => { d.classList.remove('open'); d.style.maxHeight=''; }); _crOpenDetailId = null; if (wasOpen) return; rowEl.classList.add('open'); const dr = document.getElementById(`cr-detail-${id}`); if (dr) { dr.classList.add('open'); } _crOpenDetailId = id; await loadCrSessionDetail(id); } window.toggleCrDetail = toggleCrDetail; async function loadCrSessionDetail(id) { const { fmtDuration } = AdminCtx; const inner = document.getElementById(`cr-detail-inner-${id}`); if (!inner) return; inner.innerHTML = '
'; try { const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`); const dur = fmtDuration(stats.duration_sec); inner.innerHTML = `
${stats.participant_count}
Участников
${stats.message_count}
Сообщений
${stats.page_count}
Страниц
${dur}
Длительность
${attendance.length ? `
Посещаемость
${attendance.map(a => `
${esc(a.user_name)} ${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'} ${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : 'онлайн')}
`).join('')}
` : ''} ${pages.length > 1 ? `
Страницы доски
${pages.map(p => `
Стр. ${p.page_num} ${p.stroke_count} штр.
`).join('')}
` : ''}
`; } catch(e) { inner.innerHTML = `
Ошибка: ${esc(e.message)}
`; } } function adminExportChat(id) { window.open(`/api/classroom/${id}/chat/export`, '_blank'); } window.adminExportChat = adminExportChat; async function adminDeleteSession(id) { if (!await LS.confirm('Удалить всю запись об этом уроке? Данные нельзя восстановить.', { title: 'Удалить урок', confirmText: 'Удалить', dangerous: true })) return; try { await LS.api(`/api/classroom/${id}/history`, { method: 'DELETE' }); LS.toast('Урок удалён', 'success', 2500); _crOpenDetailId = null; loadCrHistory(); } catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); } } window.adminDeleteSession = adminDeleteSession; /* ── Avatar moderation ─────────────────────────────────────────────── */ async function loadAvatarRequests() { const list = document.getElementById('av-list'); list.innerHTML = '
Загрузка...
'; 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 = '
Нет заявок на модерацию
'; if (window.lucide) lucide.createIcons(); return; } list.innerHTML = `
${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 ? `` : initials; const newAvatar = ``; const d = new Date(r.created_at).toLocaleString('ru', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' }); return `
Сейчас
${curAvatar}
Новый
${newAvatar}
${esc(r.user_name||r.user_email)}
${esc(r.user_email)} · ${d}
`; }).join('')}
`; if (window.lucide) lucide.createIcons(); } catch { list.innerHTML = '
Ошибка загрузки
'; } } 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 = ''; } } window.avatarApprove = avatarApprove; function avatarRejectPrompt(id) { const reason = prompt('Причина отклонения (необязательно):') ?? null; if (reason === null) return; avatarReject(id, reason); } window.avatarRejectPrompt = avatarRejectPrompt; 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 = ''; } } window.avatarReject = avatarReject; /* ─── init ─── */ // Initial #stats tab is .active in markup — section module will lazy-load on first switchTab. AdminSections.stats.init(); loadAvatarRequests(); // load badge count on page open if (window.lucide) lucide.createIcons(); /* ─── Hash router wiring ─── */ (function initAdminRouter() { if (!window.AdminRouter) return; function activate(route) { const name = route || 'stats'; const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]'); if (!btn) { console.warn('AdminRouter: unknown route', name); AdminRouter.navigate('#stats', { replace: true, silent: true }); const fallback = document.querySelector('.admin-nav-item[data-tab="stats"]'); if (fallback) switchTab(fallback, { fromRouter: true }); return; } if (btn.classList.contains('locked')) { LS.toast('Этот раздел доступен только администраторам', 'warn'); AdminRouter.navigate('#stats', { replace: true, silent: true }); const fallback = document.querySelector('.admin-nav-item[data-tab="stats"]'); if (fallback) switchTab(fallback, { fromRouter: true }); return; } switchTab(btn, { fromRouter: true }); } AdminRouter.on('change', (r) => activate(r.route)); // Initial dispatch: respect existing hash, else default to #stats. const initial = AdminRouter.current(); if (!initial.route) { AdminRouter.navigate('#stats', { replace: true, silent: true }); } else if (initial.route !== 'stats') { activate(initial.route); } })();