feat(admin): phase 2 — split admin.js into 13 section modules

Replace ~3500L admin.js monolith with thin orchestrator (~700L) +

14 IIFE-wrapped per-section modules under /js/admin/sections/.

Section modules expose AdminSections.<name>.init/reload (lazy init via

switchTab/router) and re-expose onclick handlers via window.X for

backward compat. Shared helpers (MODES/DIFFS, fmtDate, pctClass,

renderMath, qTypeBadge, pagination) live in /js/admin/_shared.js

exposed on window.AdminCtx.

switchTab now dispatches to AdminSections via ROUTE_TO_SECTION map;

non-extracted system tabs (topics/audit/errors/health/classroom/avatars)

remain inline in admin.js. user-panel overlay markup untouched — Phase 6

will remove it.
This commit is contained in:
Maxim Dolgolyov
2026-05-16 22:50:14 +03:00
parent 8a7bed487f
commit 92030b462c
17 changed files with 3877 additions and 3553 deletions
+15
View File
@@ -1982,6 +1982,21 @@
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/admin/router.js"></script>
<script src="/js/admin/_shared.js"></script>
<script src="/js/admin/sections/stats.js"></script>
<script src="/js/admin/sections/sublog.js"></script>
<script src="/js/admin/sections/sims.js"></script>
<script src="/js/admin/sections/games.js"></script>
<script src="/js/admin/sections/tpl.js"></script>
<script src="/js/admin/sections/subjects.js"></script>
<script src="/js/admin/sections/permissions.js"></script>
<script src="/js/admin/sections/shop.js"></script>
<script src="/js/admin/sections/gam.js"></script>
<script src="/js/admin/sections/assignments.js"></script>
<script src="/js/admin/sections/tests.js"></script>
<script src="/js/admin/sections/questions.js"></script>
<script src="/js/admin/sections/users.js"></script>
<script src="/js/admin/sections/sessions.js"></script>
<script src="/js/admin/admin.js"></script>
</div>
</div>
+129
View File
@@ -0,0 +1,129 @@
'use strict';
/* Admin shared helpers — referenced by admin.js orchestrator + every section module.
* Exposed on window.AdminCtx (filled by admin.js after LS.initPage()) and
* on window directly for utility functions used by HTML onclicks.
*/
(function () {
'use strict';
/* ─── Constants ─── */
const MODES = { exam:'Экзамен', practice:'Тренировка', repeat:'Обычный', ct:'ЦТ/ЦЭ', topic:'По теме', random:'Случайный' };
const DIFFS = { 1:'Лёгкий', 2:'Средний', 3:'Сложный' };
const DIFF_LABELS = DIFFS;
const TYPE_LABELS = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Краткий', matching:'Сопоставление' };
/* ─── Generic formatters ─── */
function pctClass(p) { return p === null ? '' : p >= 75 ? 'pct-hi' : p >= 50 ? 'pct-mid' : 'pct-lo'; }
function fmtDate(d) { return new Date(d).toLocaleDateString('ru', { day:'numeric', month:'short', year:'numeric' }); }
function fmtTime(sec) {
if (!sec || sec < 0) return '—';
const m = Math.floor(sec / 60), s = sec % 60;
return m ? `${m} мин ${s} сек` : `${s} сек`;
}
function fmtDuration(sec) {
if (!sec || sec < 0) return '—';
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
if (h) return `${h}ч ${m}м`;
if (m) return `${m} мин ${s} сек`;
return `${s} сек`;
}
/* ─── KaTeX rendering ─── */
const KATEX_OPTS = {
delimiters: [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false,
};
function renderMath(el) {
if (!el) return;
const run = () => { if (window.renderMathInElement) renderMathInElement(el, KATEX_OPTS); };
if (window._katexReady) run(); else window._katexCb = run;
}
/* ─── Question type badges (used by tests + subjects sections) ─── */
function qTypeBadge(type) {
const MAP = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Ответ', matching:'Сопост.' };
const CLR = { single:'rgba(155,93,229,0.12)', multi:'rgba(6,214,224,0.12)', true_false:'rgba(255,179,71,0.14)', short_answer:'rgba(6,214,100,0.12)', matching:'rgba(241,91,181,0.10)' };
const TXT = { single:'var(--violet)', multi:'#05aab3', true_false:'var(--amber)', short_answer:'var(--green)', matching:'var(--pink)' };
return `<span class="tst-q-badge" style="background:${CLR[type]||'rgba(15,23,42,0.06)'};color:${TXT[type]||'var(--text-3)'}">${MAP[type]||type}</span>`;
}
function qOptsPreview(q) {
if (q.type === 'short_answer') return q.correct_text ? `<span class="tst-q-opts">Ответ: ${esc(q.correct_text)}</span>` : '';
if (!q.options?.length) return '';
const correct = q.options.filter(o => o.is_correct).map(o => esc(o.text)).join(', ');
return `<span class="tst-q-opts"><i data-lucide="check" style="width:12px;height:12px;vertical-align:-2px"></i> ${correct}</span>`;
}
/* ─── Pagination controls (users + future tables) ─── */
function ensurePgnStyles() {
if (document.getElementById('pgn-bar-style')) return;
const s = document.createElement('style');
s.id = 'pgn-bar-style';
s.textContent = `
.pgn-bar { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:14px 4px 4px; font-size:0.85rem; color:var(--text-3); }
.pgn-info { font-weight:600; }
.pgn-ctrls { display:flex; align-items:center; gap:4px; }
.pgn-btn { min-width:32px; height:32px; padding:0 10px; border:1px solid var(--border); background:var(--surface); border-radius:8px; cursor:pointer; font-weight:600; font-family:inherit; font-size:0.85rem; color:var(--text-2); transition:background .12s, color .12s, border-color .12s; }
.pgn-btn:hover:not(:disabled) { background:rgba(155,93,229,.08); color:var(--violet); border-color:rgba(155,93,229,.3); }
.pgn-btn.active { background:var(--violet); color:#fff; border-color:var(--violet); }
.pgn-btn:disabled { opacity:.4; cursor:not-allowed; }
.pgn-ellip { padding:0 6px; color:var(--text-3); }
`;
document.head.appendChild(s);
}
function renderPgnControls(elId, page, total, perPage, gotoFn) {
const bar = document.getElementById(elId);
if (!bar) return;
const pages = Math.max(1, Math.ceil(total / perPage));
if (pages <= 1) { bar.style.display = 'none'; return; }
ensurePgnStyles();
const from = (page - 1) * perPage + 1;
const to = Math.min(page * perPage, total);
const nums = new Set([1, pages, page, page - 1, page + 1, page - 2, page + 2]);
const sorted = [...nums].filter(n => n >= 1 && n <= pages).sort((a, b) => a - b);
const numHtml = sorted.map((n, i) => {
const prev = sorted[i - 1];
const gap = prev && n - prev > 1 ? '<span class="pgn-ellip">…</span>' : '';
return `${gap}<button class="pgn-btn${n === page ? ' active' : ''}" onclick="${gotoFn}(${n})">${n}</button>`;
}).join('');
bar.innerHTML = `
<div class="pgn-info">${from}${to} из ${total}</div>
<div class="pgn-ctrls">
<button class="pgn-btn" onclick="${gotoFn}(${page - 1})" ${page <= 1 ? 'disabled' : ''}>←</button>
${numHtml}
<button class="pgn-btn" onclick="${gotoFn}(${page + 1})" ${page >= pages ? 'disabled' : ''}>→</button>
</div>`;
bar.style.display = '';
}
/* ─── Export ─── */
window.AdminCtx = window.AdminCtx || {
// filled by admin.js after LS.initPage():
user: null,
isTeacher: false,
isAdmin: false,
// constants:
MODES,
DIFFS,
DIFF_LABELS,
TYPE_LABELS,
// formatters:
pctClass,
fmtDate,
fmtTime,
fmtDuration,
// rendering:
renderMath,
qTypeBadge,
qOptsPreview,
// pagination:
renderPgnControls,
ensurePgnStyles,
};
window.AdminSections = window.AdminSections || {};
})();
+663 -3553
View File
File diff suppressed because it is too large Load Diff
+477
View File
@@ -0,0 +1,477 @@
'use strict';
/* admin → assignments section (классные/индивидуальные задания) */
(function () {
'use strict';
let inited = false;
let allAssignments = [];
let editingAId = null;
const SUBJ_NAMES = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
const SUBJ_COLORS_A = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
const SUBJ_ICONS_A = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
let _aFilter = 'all';
// create-modal state
let _afSrc = 'random';
let _afLoadedTests = [];
let _acSrc = 'random';
let _acTarget = 'class';
let _acFileId = null, _acAllFiles = null;
let _acStudentId = null, _acAllStudents = null;
async function load() {
document.getElementById('a-body').innerHTML = '<div class="spinner"></div>';
try {
allAssignments = await LS.teacherAssignments();
renderAssignments();
} catch (e) {
document.getElementById('a-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function setAFilter(f) {
_aFilter = f;
document.querySelectorAll('.a-f-chip').forEach(c =>
c.classList.toggle('active', c.textContent.trim() === {all:'Все',active:'Активные',overdue:'Просрочены',done:'Завершены'}[f])
);
renderAssignments();
}
function aClassify(a) {
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
if (pct === 100) return 'done';
if (a.deadline && new Date(a.deadline) < new Date()) return 'overdue';
return 'active';
}
function renderAssignments() {
const { MODES, pctClass } = AdminCtx;
const subjF = document.getElementById('a-subject').value;
const searchF = document.getElementById('a-search').value.toLowerCase();
const sortF = document.getElementById('a-sort')?.value || 'date';
let list = allAssignments.filter(a => {
if (subjF && a.subject_slug !== subjF) return false;
if (searchF && !a.title.toLowerCase().includes(searchF)) return false;
if (_aFilter === 'active' && aClassify(a) !== 'active') return false;
if (_aFilter === 'overdue' && aClassify(a) !== 'overdue') return false;
if (_aFilter === 'done' && aClassify(a) !== 'done') return false;
return true;
});
list = [...list].sort((a, b) => {
if (sortF === 'deadline') {
const da = a.deadline ? new Date(a.deadline) : new Date(9e15);
const db = b.deadline ? new Date(b.deadline) : new Date(9e15);
return da - db;
}
if (sortF === 'progress_asc') {
const pa = a.total_members ? a.completed_count / a.total_members : 0;
const pb = b.total_members ? b.completed_count / b.total_members : 0;
return pa - pb;
}
if (sortF === 'progress_desc') {
const pa = a.total_members ? a.completed_count / a.total_members : 0;
const pb = b.total_members ? b.completed_count / b.total_members : 0;
return pb - pa;
}
return 0;
});
const all = allAssignments;
const nActive = all.filter(a => aClassify(a) === 'active').length;
const nOverdue = all.filter(a => aClassify(a) === 'overdue').length;
const nDone = all.filter(a => aClassify(a) === 'done').length;
document.getElementById('a-summary').innerHTML = [
`<span class="a-sum-chip s-all">Всего: ${all.length}</span>`,
nActive ? `<span class="a-sum-chip s-active">Активных: ${nActive}</span>` : '',
nOverdue ? `<span class="a-sum-chip s-overdue">Просрочено: ${nOverdue}</span>` : '',
nDone ? `<span class="a-sum-chip s-done">Завершено: ${nDone}</span>` : '',
].join('');
document.getElementById('a-count').textContent = `${list.length} заданий`;
const container = document.getElementById('a-body');
if (!list.length) {
container.innerHTML = '<div class="empty">Заданий нет</div>';
return;
}
const now = new Date();
container.innerHTML = list.map(a => {
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
const cls = aClassify(a);
const rowCls = cls === 'overdue' ? 'a-overdue' : cls === 'done' ? 'a-done' : '';
const sColor = SUBJ_COLORS_A[a.subject_slug] || '#9B5DE5';
const dlMs = a.deadline ? new Date(a.deadline) - now : Infinity;
const isUrgent = cls === 'active' && dlMs > 0 && dlMs < 24 * 3600 * 1000;
const dl = a.deadline
? new Date(a.deadline).toLocaleDateString('ru', {day:'numeric', month:'short'})
: null;
const targetStr = a.target_user_id
? esc(a.target_user_name || 'Ученик')
: esc(a.class_name || '—');
const metaParts = [
targetStr,
SUBJ_NAMES[a.subject_slug] || a.subject_slug,
`<span class="mode-badge mode-${a.mode}">${MODES[a.mode]||a.mode}</span>`,
a.count + ' вопр.',
dl ? `до ${dl}` : null,
isUrgent ? `<span class="a-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> срочно</span>` : null,
cls === 'overdue' ? `<span class="a-tag-over">просрочено</span>` : null,
].filter(Boolean);
const barColor = pct >= 75 ? '#06D6A0' : pct >= 40 ? '#F59E0B' : '#F15BB5';
const pctLabel = pct !== null ? `${pct}%` : '—';
return `<div class="a-row ${rowCls}${isUrgent ? ' a-urgent' : ''}" style="--ac:${sColor}">
<div class="a-icon" style="background:${sColor}18;color:${sColor}"><i data-lucide="${SUBJ_ICONS_A[a.subject_slug]||'file-text'}" style="width:18px;height:18px"></i></div>
<div class="a-main">
<div class="a-title">${esc(a.title)}</div>
<div class="a-meta">${metaParts.join(' · ')}</div>
</div>
<div class="a-prog">
<div class="a-prog-nums">
<span>${a.completed_count} / ${a.total_members} сдали</span>
<span class="a-prog-pct ${pctClass(pct)}">${pctLabel}</span>
</div>
<div class="a-prog-bar">
<div class="a-prog-fill" style="width:${pct||0}%;background:${barColor}"></div>
</div>
</div>
<div class="a-actions">
<button class="btn-edit-q" onclick="openAModal(${a.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteAsgn(${a.id})"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
function setAfSrc(src) {
_afSrc = src;
document.querySelectorAll('[data-afsrc]').forEach(b => b.classList.toggle('active', b.dataset.afsrc === src));
document.getElementById('af-random-fields').style.display = src === 'random' ? '' : 'none';
document.getElementById('af-test-fields').style.display = src === 'test' ? '' : 'none';
}
async function openAModal(id) {
const a = allAssignments.find(x => x.id === id);
if (!a) return;
editingAId = id;
document.getElementById('a-modal-title').textContent = `Редактировать: ${a.title}`;
document.getElementById('af-title').value = a.title;
document.getElementById('af-deadline').value = a.deadline ? a.deadline.split('T')[0] : '';
document.getElementById('af-error').textContent = '';
const testSel = document.getElementById('af-test');
testSel.innerHTML = '<option value="">Загрузка…</option>';
try {
_afLoadedTests = await LS.getTests();
testSel.innerHTML = _afLoadedTests.length
? '<option value="">— выберите тест —</option>' + _afLoadedTests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
: '<option value="">Нет тестов</option>';
} catch {
testSel.innerHTML = '<option value="">Ошибка загрузки</option>';
_afLoadedTests = [];
}
if (a.test_id) {
setAfSrc('test');
testSel.value = a.test_id;
document.getElementById('af-mode-test').value = a.mode;
} else {
setAfSrc('random');
document.getElementById('af-subject').value = a.subject_slug;
document.getElementById('af-mode').value = a.mode;
document.getElementById('af-count').value = a.count;
}
document.getElementById('a-modal').classList.add('open');
setTimeout(() => document.getElementById('af-title').focus(), 80);
}
function closeAModal() {
document.getElementById('a-modal').classList.remove('open');
editingAId = null;
}
async function saveAssignment() {
const title = document.getElementById('af-title').value.trim();
const deadline = document.getElementById('af-deadline').value || null;
const errEl = document.getElementById('af-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
let payload = { title, deadline };
if (_afSrc === 'test') {
const test_id = document.getElementById('af-test').value;
const mode = document.getElementById('af-mode-test').value;
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
const testObj = _afLoadedTests.find(t => t.id === Number(test_id));
if (testObj && testObj.question_count === 0) { errEl.textContent = 'В выбранном тесте нет вопросов'; return; }
payload = { ...payload, test_id: Number(test_id), mode };
} else {
const subject_slug = document.getElementById('af-subject').value;
const mode = document.getElementById('af-mode').value;
const count = Number(document.getElementById('af-count').value);
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!count || count < 1) { errEl.textContent = 'Введите количество вопросов'; return; }
payload = { ...payload, subject_slug, mode, count, test_id: null };
}
const btn = document.getElementById('af-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
await LS.updateAssignment(editingAId, payload);
const idx = allAssignments.findIndex(x => x.id === editingAId);
if (idx !== -1) Object.assign(allAssignments[idx], payload);
closeAModal();
renderAssignments();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ─── Create assignment modal ─── */
function setAcTarget(t) {
_acTarget = t;
document.querySelectorAll('[data-actgt]').forEach(b => b.classList.toggle('active', b.dataset.actgt === t));
document.getElementById('acf-class-field').style.display = t === 'class' ? '' : 'none';
document.getElementById('acf-user-field').style.display = t === 'user' ? '' : 'none';
if (t === 'user' && !_acAllStudents) loadAcStudents();
}
async function loadAcStudents() {
const drop = document.getElementById('acf-student-drop');
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Загрузка…</div>';
drop.style.display = '';
try {
_acAllStudents = await LS.getStudentsList();
openAcStudentDrop();
} catch(e) {
_acAllStudents = [];
drop.innerHTML = `<div style="padding:8px 12px;font-size:13px;color:#ef4444">Ошибка загрузки: ${e.message}</div>`;
}
}
function filterAcStudents(q) {
openAcStudentDrop(q);
}
function openAcStudentDrop(q) {
const drop = document.getElementById('acf-student-drop');
if (_acAllStudents === null) { loadAcStudents(); return; }
const list = _acAllStudents;
const term = (q !== undefined ? q : document.getElementById('acf-student-search').value).toLowerCase().trim();
const filtered = term ? list.filter(s => s.name.toLowerCase().includes(term) || s.email.toLowerCase().includes(term)) : list;
if (!filtered.length) {
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Нет учеников</div>';
drop.style.display = '';
return;
}
drop.innerHTML = filtered.slice(0, 50).map(s =>
`<div style="padding:8px 12px;cursor:pointer;border-bottom:1px solid #f3f4f6;font-size:13px" data-id="${s.id}" data-name="${esc(s.name)}" data-email="${esc(s.email)}" onmousedown="selectAcStudent(+this.dataset.id,this.dataset.name,this.dataset.email)" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background=''">${esc(s.name)} <span style="color:#9ca3af">${esc(s.email)}</span></div>`
).join('');
drop.style.display = '';
}
function closeAcStudentDrop() {
document.getElementById('acf-student-drop').style.display = 'none';
}
function selectAcStudent(id, name, email) {
_acStudentId = id;
document.getElementById('acf-student-search').value = name;
document.getElementById('acf-student-selected').textContent = `${name} (${email})`;
document.getElementById('acf-student-selected').style.display = '';
closeAcStudentDrop();
}
function setAcSrc(src) {
_acSrc = src;
document.querySelectorAll('[data-src]').forEach(b => b.classList.toggle('active', b.dataset.src === src));
document.getElementById('acf-random-fields').style.display = src === 'random' ? '' : 'none';
document.getElementById('acf-test-fields').style.display = src === 'test' ? '' : 'none';
document.getElementById('acf-file-fields').style.display = src === 'file' ? '' : 'none';
if (src === 'file' && !_acAllFiles) loadAcFiles();
}
async function loadAcFiles() {
try {
_acAllFiles = await LS.getFiles();
renderAcFiles('');
} catch { _acAllFiles = []; }
}
function renderAcFiles(q) {
const el = document.getElementById('acf-file-list');
if (!_acAllFiles) { el.innerHTML = '<div style="padding:10px;color:var(--text-3);font-size:.82rem;text-align:center">Загрузка…</div>'; return; }
const lq = q.toLowerCase();
const items = q ? _acAllFiles.filter(f => (f.title||'').toLowerCase().includes(lq)) : _acAllFiles;
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
if (!items.length) { el.innerHTML = '<div style="padding:10px;color:var(--text-3);font-size:.82rem;text-align:center">Нет файлов</div>'; return; }
el.innerHTML = items.map(f => `
<div onclick="selectAcFile(${f.id},'${esc(f.title||'Файл')}','${f.subject_slug||''}')"
style="padding:9px 12px;cursor:pointer;border-bottom:1px solid rgba(15,23,42,0.07);display:flex;align-items:center;gap:8px;${_acFileId===f.id?'background:rgba(155,93,229,0.08);':''} transition:background .15s">
<div style="flex:1">
<div style="font-size:.84rem;font-weight:600">${esc(f.title||'Файл')}</div>
<div style="font-size:.74rem;color:var(--text-3)">${SUBJ[f.subject_slug]||f.subject_slug||''}</div>
</div>
${_acFileId===f.id ? '<span style="color:var(--violet)"><i data-lucide="check" style="width:15px;height:15px"></i></span>' : ''}
</div>`).join('');
if (window.lucide) lucide.createIcons();
}
function filterAcFiles(q) { renderAcFiles(q); }
function selectAcFile(id, title, subject_slug) {
_acFileId = id;
renderAcFiles(document.getElementById('acf-file-search').value);
const sel = document.getElementById('acf-file-selected');
sel.textContent = 'Выбран: ' + title;
sel.style.display = '';
}
async function openCreateAModal() {
_acSrc = 'random'; _acTarget = 'class'; _acFileId = null; _acStudentId = null; _acAllStudents = null;
setAcSrc('random');
setAcTarget('class');
loadAcStudents();
document.getElementById('acf-title').value = '';
document.getElementById('acf-subject').value = '';
document.getElementById('acf-mode').value = 'exam';
document.getElementById('acf-mode-test').value = 'exam';
document.getElementById('acf-count').value = '25';
document.getElementById('acf-deadline').value = '';
document.getElementById('acf-student-search').value = '';
document.getElementById('acf-student-selected').style.display = 'none';
_acStudentId = null;
document.getElementById('acf-error').textContent = '';
document.getElementById('acf-file-search').value = '';
document.getElementById('acf-file-selected').style.display = 'none';
const [clsSel, testSel] = [document.getElementById('acf-class'), document.getElementById('acf-test')];
clsSel.innerHTML = '<option value="">Загрузка…</option>';
testSel.innerHTML = '<option value="">Загрузка…</option>';
const [classesP, testsP] = await Promise.allSettled([LS.getClasses(), LS.getTests()]);
if (classesP.status === 'fulfilled') {
const classes = classesP.value;
clsSel.innerHTML = classes.length
? '<option value="">— выберите класс —</option>' + classes.map(c => `<option value="${c.id}">${esc(c.name)} (${c.member_count} уч.)</option>`).join('')
: '<option value="">Нет классов — создайте класс</option>';
} else {
clsSel.innerHTML = `<option value="">Ошибка загрузки классов</option>`;
}
if (testsP.status === 'fulfilled') {
const tests = testsP.value;
testSel.innerHTML = tests.length
? '<option value="">— выберите тест —</option>' + tests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
: '<option value="">Нет тестов — создайте тест</option>';
} else {
testSel.innerHTML = `<option value="">Ошибка загрузки тестов</option>`;
}
document.getElementById('ac-modal').classList.add('open');
setTimeout(() => document.getElementById('acf-title').focus(), 80);
}
function closeCreateAModal() {
document.getElementById('ac-modal').classList.remove('open');
}
async function saveNewAssignment() {
const title = document.getElementById('acf-title').value.trim();
const deadline = document.getElementById('acf-deadline').value || null;
const errEl = document.getElementById('acf-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
let payload = { title, deadline };
if (_acSrc === 'file') {
if (!_acFileId) { errEl.textContent = 'Выберите файл из библиотеки'; return; }
const f = _acAllFiles.find(x => x.id === _acFileId);
payload = { ...payload, file_id: _acFileId, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1 };
} else if (_acSrc === 'test') {
const test_id = document.getElementById('acf-test').value;
const mode = document.getElementById('acf-mode-test').value;
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
const selOpt = document.querySelector(`#acf-test option[value="${test_id}"]`);
if (selOpt && selOpt.textContent.includes('(0 вопр.)')) { errEl.textContent = 'В выбранном тесте нет вопросов. Добавьте вопросы во вкладке «Тесты».'; return; }
payload = { ...payload, test_id: Number(test_id), mode };
} else {
const subject_slug = document.getElementById('acf-subject').value;
const mode = document.getElementById('acf-mode').value;
const count = Number(document.getElementById('acf-count').value);
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!count || count < 1) { errEl.textContent = 'Укажите количество вопросов'; return; }
payload = { ...payload, subject_slug, mode, count };
}
const btn = document.getElementById('acf-save');
btn.disabled = true; btn.textContent = 'Создание…';
try {
if (_acTarget === 'user') {
if (!_acStudentId) { errEl.textContent = 'Выберите ученика из списка'; btn.disabled=false; btn.textContent='Создать'; return; }
await LS.createDirectAssignment({ ...payload, student_id: _acStudentId });
} else {
const class_id = document.getElementById('acf-class').value;
if (!class_id) { errEl.textContent = 'Выберите класс'; btn.disabled=false; btn.textContent='Создать'; return; }
await LS.createAssignment(class_id, payload);
}
closeCreateAModal();
await load();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Создать';
}
}
async function deleteAsgn(id) {
const a = allAssignments.find(x => x.id === id);
if (!await LS.confirm(`Удалить задание «${a?.title}»?\nВсе связанные сессии будут удалены.`, { title: 'Удалить задание', confirmText: 'Удалить' })) return;
try {
await LS.deleteAssignment(id);
allAssignments = allAssignments.filter(x => x.id !== id);
renderAssignments();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers used by HTML/inline onclicks
window.loadAssignments = load;
window.renderAssignments = renderAssignments;
window.setAFilter = setAFilter;
window.setAfSrc = setAfSrc;
window.openAModal = openAModal;
window.closeAModal = closeAModal;
window.saveAssignment = saveAssignment;
window.setAcTarget = setAcTarget;
window.filterAcStudents = filterAcStudents;
window.openAcStudentDrop = openAcStudentDrop;
window.closeAcStudentDrop = closeAcStudentDrop;
window.selectAcStudent = selectAcStudent;
window.setAcSrc = setAcSrc;
window.filterAcFiles = filterAcFiles;
window.selectAcFile = selectAcFile;
window.openCreateAModal = openCreateAModal;
window.closeCreateAModal = closeCreateAModal;
window.saveNewAssignment = saveNewAssignment;
window.deleteAsgn = deleteAsgn;
window.AdminSections = window.AdminSections || {};
window.AdminSections.assignments = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+183
View File
@@ -0,0 +1,183 @@
'use strict';
/* admin → gam (gamification) section: stats + top + recent XP + purchases + award XP */
(function () {
'use strict';
let inited = false;
let _gamSearchTimer = null;
let _gamAwarding = false;
const XP_REASONS = {
'daily_activity': ['sun', '#F59E0B', 'Ежедневная активность'],
'correct_answers':['check-circle', '#10B981', 'Правильные ответы'],
'test_complete': ['file-text', '#06B6D4', 'Тест завершён'],
'test_90+': ['zap', '#9B5DE5', 'Тест на 90%+'],
'test_perfect': ['trophy', '#F59E0B', 'Идеальный тест (100%)'],
'lab_experiment': ['atom', '#06D6A0', 'Лабораторный эксперимент'],
'daily_goal': ['target', '#EF476F', 'Ежедневная цель выполнена'],
'Admin award': ['crown', '#9B5DE5', 'Начисление администратором'],
};
function fmtXPReason(reason) {
if (!reason) return '—';
const entry = XP_REASONS[reason];
if (entry) {
const [icon, color, label] = entry;
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:${color};display:inline-flex">${lsIcon(icon,14)}</span>${label}</span>`;
}
if (reason.startsWith('achievement:')) {
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#F59E0B;display:inline-flex">${lsIcon('award',14)}</span>Достижение: ${esc(reason.slice(12))}</span>`;
}
if (reason.startsWith('Испытание:')) {
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#EF476F;display:inline-flex">${lsIcon('swords',14)}</span>${esc(reason)}</span>`;
}
return esc(reason);
}
async function load() {
const { fmtDate } = AdminCtx;
try {
const stats = await LS.adminGamStats();
document.getElementById('gam-stats-grid').innerHTML = `
<div class="stat-card" style="--stat-top:var(--violet)">
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="zap" class="stat-icon"></i></div>
<div class="stat-val violet">${stats.totalXP}</div>
<div class="stat-label">Суммарный XP</div>
</div>
<div class="stat-card" style="--stat-top:var(--cyan)">
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="coins" class="stat-icon"></i></div>
<div class="stat-val cyan">${stats.totalCoins}</div>
<div class="stat-label">Суммарные монеты</div>
</div>
<div class="stat-card" style="--stat-top:var(--green)">
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="bar-chart-3" class="stat-icon"></i></div>
<div class="stat-val green">${(stats.avgLevel ?? 0).toFixed(1)}</div>
<div class="stat-label">Средний уровень</div>
</div>
<div class="stat-card" style="--stat-top:var(--amber, #FFB347)">
<div class="stat-card-icon" style="background:rgba(255,179,71,0.1)"><i data-lucide="trophy" class="stat-icon"></i></div>
<div class="stat-val" style="color:var(--amber, #FFB347)">${stats.achievementCount}</div>
<div class="stat-label">Достижений выдано</div>
</div>
<div class="stat-card" style="--stat-top:#FF9F1C">
<div class="stat-card-icon" style="background:rgba(255,159,28,0.1)"><i data-lucide="shopping-bag" class="stat-icon"></i></div>
<div class="stat-val" style="color:#FF9F1C">${stats.totalPurchases || 0}</div>
<div class="stat-label">Покупок</div>
</div>`;
// Top-10
const topBody = document.getElementById('gam-top-body');
if (stats.topByXP?.length) {
topBody.innerHTML = stats.topByXP.slice(0, 10).map((u, i) => `<tr>
<td><strong>${i + 1}</strong></td>
<td>${esc(u.name || u.email || 'ID:' + (u.id || u.user_id))}</td>
<td><span style="color:var(--violet);font-weight:700">${u.xp}</span></td>
<td>${u.level}</td>
<td>${u.coins} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
</tr>`).join('');
} else {
topBody.innerHTML = '<tr><td colspan="5" class="empty">Нет данных</td></tr>';
}
// Recent XP
const logBody = document.getElementById('gam-log-body');
if (stats.recentXP?.length) {
logBody.innerHTML = stats.recentXP.slice(0, 20).map(e => `<tr>
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(e.created_at || e.date)}</td>
<td>${esc(e.name || e.user_name || '—')}</td>
<td><span style="color:var(--violet);font-weight:700">+${e.amount}</span></td>
<td style="font-size:0.82rem;color:var(--text-2)">${fmtXPReason(e.reason)}</td>
</tr>`).join('');
} else {
logBody.innerHTML = '<tr><td colspan="4" class="empty">Нет данных</td></tr>';
}
// Purchases
const purchBody = document.getElementById('gam-purchases-body');
if (stats.recentPurchases?.length) {
purchBody.innerHTML = stats.recentPurchases.slice(0, 20).map(p => `<tr>
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(p.purchased_at)}</td>
<td>${esc(p.user_name || '—')}</td>
<td style="font-weight:600">${esc(p.item_name || '—')}</td>
<td><span class="badge" style="font-size:0.7rem">${esc(p.type || '—')}</span></td>
<td style="color:var(--amber,#FFB347);font-weight:700">${p.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px"></i></td>
</tr>`).join('');
} else {
purchBody.innerHTML = '<tr><td colspan="5" class="empty">Нет покупок</td></tr>';
}
if (window.lucide) lucide.createIcons();
} catch(e) {
document.getElementById('gam-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
async function gamSearchUser(q, prefix) {
clearTimeout(_gamSearchTimer);
const box = document.getElementById(prefix + '-results');
if (q.length < 2) { box.classList.remove('open'); return; }
_gamSearchTimer = setTimeout(async () => {
try {
const r = await LS.adminGetUsers({ q, limit: 8 });
box.innerHTML = (r.users || []).map(u => `<div class="us-item" onclick="gamPickUser(${u.id}, '${esc(u.name || u.email)}', '${prefix}')">
<span>${esc(u.name || u.email)}</span><span class="us-role">${u.role}</span>
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
box.classList.add('open');
} catch(e) { box.classList.remove('open'); }
}, 300);
}
function gamPickUser(id, name, prefix) {
document.getElementById(prefix + '-uid').value = id;
document.getElementById(prefix + '-user').value = name;
document.getElementById(prefix + '-results').classList.remove('open');
}
async function gamAdminAward() {
if (_gamAwarding) return;
const userId = parseInt(document.getElementById('gam-award-uid').value);
const xp = parseInt(document.getElementById('gam-award-xp').value) || 0;
const coins = parseInt(document.getElementById('gam-award-coins').value) || 0;
const reason = document.getElementById('gam-award-reason').value.trim();
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
if (!xp && !coins) { LS.toast('Введите XP или монеты', 'error'); return; }
_gamAwarding = true;
try {
const r = await LS.adminGamAward({ userId, xp, coins, reason });
LS.toast(`Начислено! XP: ${r.xp}, Уровень: ${r.level}, Монеты: ${r.coins}`, 'success');
document.getElementById('gam-award-uid').value = '';
document.getElementById('gam-award-user').value = '';
document.getElementById('gam-award-reason').value = '';
inited = false;
await load();
inited = true;
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { _gamAwarding = false; }
}
async function gamAdminReset() {
const userId = parseInt(document.getElementById('gam-reset-uid').value);
const userName = document.getElementById('gam-reset-user').value;
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
if (!await LS.confirm(`ВСЕ XP, монеты и достижения «${userName}» будут удалены безвозвратно.`, { title: 'Сбросить прогресс?', confirmText: 'Сбросить', danger: true })) return;
try {
await LS.adminGamReset({ userId });
LS.toast('Прогресс сброшен', 'success');
document.getElementById('gam-reset-uid').value = '';
document.getElementById('gam-reset-user').value = '';
inited = false;
await load();
inited = true;
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.gamSearchUser = gamSearchUser;
window.gamPickUser = gamPickUser;
window.gamAdminAward = gamAdminAward;
window.gamAdminReset = gamAdminReset;
window.AdminSections = window.AdminSections || {};
window.AdminSections.gam = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+132
View File
@@ -0,0 +1,132 @@
'use strict';
/* admin → games (game features + free-student features) section */
(function () {
'use strict';
let inited = false;
const GAME_FEATURES = [
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, пищевые сети, квесты', icon: 'leaf' },
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и достижений — игровой прогресс ученика', icon: 'layers' },
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
];
const FS_FEATURES = [
{ key: 'gamification', label: 'Геймификация', desc: 'XP, уровни, достижения, монеты, стрики, магазин', icon: 'trophy' },
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически', icon: 'grid-3x3' },
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, квесты', icon: 'leaf' },
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и игровой прогресс ученика', icon: 'layers' },
{ key: 'lab', label: 'Лаборатория', desc: 'Виртуальные симуляции и интерактивные опыты', icon: 'flask-conical' },
{ key: 'knowledge_map',label: 'Карта знаний', desc: 'Визуальная карта тем и связей между понятиями', icon: 'map' },
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для повторения терминов и понятий', icon: 'square-stack' },
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями и постами', icon: 'layout-dashboard' },
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
];
async function loadGamesAdmin() {
const grid = document.getElementById('games-features-grid');
try {
const features = await LS.api('/api/admin/features');
grid.innerHTML = '';
for (const f of GAME_FEATURES) {
const enabled = features[f.key] !== false;
const card = document.createElement('div');
card.className = 'perm-card' + (enabled ? ' enabled' : '');
card.innerHTML = `
<div class="perm-info">
<div class="perm-label"><i data-lucide="${f.icon}" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>${f.label}</div>
<div class="perm-desc">${f.desc}</div>
</div>
<label class="perm-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleGameFeature('${f.key}', this.checked, this)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>`;
grid.appendChild(card);
}
if (window.lucide) lucide.createIcons();
} catch(e) {
grid.innerHTML = '<div class="error">Ошибка загрузки</div>';
}
}
async function toggleGameFeature(key, enabled, checkbox) {
try {
await LS.api('/api/admin/features', {
method: 'PATCH',
body: JSON.stringify({ [key]: enabled }),
});
const card = checkbox.closest('.perm-card');
if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? 'Функция включена' : 'Функция отключена', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
}
}
async function loadFsFeatures() {
const grid = document.getElementById('fs-features-grid');
try {
const features = await LS.api('/api/admin/free-student-features');
grid.innerHTML = '';
for (const f of FS_FEATURES) {
const enabled = features[f.key] !== false;
const card = document.createElement('div');
card.className = 'perm-card' + (enabled ? ' enabled' : '');
card.innerHTML = `
<div class="perm-info">
<div class="perm-label"><i data-lucide="${f.icon}" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>${f.label}</div>
<div class="perm-desc">${f.desc}</div>
</div>
<label class="perm-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleFsFeature('${f.key}', this.checked, this)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>`;
grid.appendChild(card);
}
if (window.lucide) lucide.createIcons();
} catch(e) {
grid.innerHTML = '<div class="error">Ошибка загрузки</div>';
}
}
async function toggleFsFeature(key, enabled, checkbox) {
try {
await LS.api('/api/admin/free-student-features', {
method: 'PATCH',
body: JSON.stringify({ [key]: enabled }),
});
const card = checkbox.closest('.perm-card');
if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? 'Модуль включён' : 'Модуль отключён', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
}
}
async function load() {
await loadGamesAdmin();
await loadFsFeatures();
}
window.toggleGameFeature = toggleGameFeature;
window.toggleFsFeature = toggleFsFeature;
window.AdminSections = window.AdminSections || {};
window.AdminSections.games = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+68
View File
@@ -0,0 +1,68 @@
'use strict';
/* admin → permissions section (role-based teacher/student permissions) */
(function () {
'use strict';
let inited = false;
let _permData = null;
async function load() {
try {
_permData = await LS.getPermissions();
renderPermissions();
} catch(e) {
document.getElementById('perm-teacher').innerHTML =
`<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
}
}
function renderPermissions() {
if (!_permData) return;
const { permissions, definitions } = _permData;
['teacher', 'student'].forEach(role => {
const container = document.getElementById('perm-' + role);
const defs = definitions.filter(d => d.role === role);
container.innerHTML = defs.map(def => {
const enabled = permissions[role]?.[def.key] ?? def.default;
return `
<div class="perm-card${enabled ? ' enabled' : ''}" id="perm-card-${role}-${def.key.replace('.','_')}">
<div class="perm-info">
<div class="perm-label">${esc(def.label)}</div>
<div class="perm-desc">${esc(def.desc)}</div>
</div>
<label class="perm-toggle" title="${enabled ? 'Выключить' : 'Включить'}">
<input type="checkbox" ${enabled ? 'checked' : ''}
onchange="togglePermission('${esc(role)}','${esc(def.key)}',this.checked,this)">
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>`;
}).join('');
});
}
async function togglePermission(role, key, enabled, checkbox) {
checkbox.disabled = true;
try {
await LS.setPermission(role, key, enabled);
if (!_permData.permissions[role]) _permData.permissions[role] = {};
_permData.permissions[role][key] = enabled;
const safeKey = key.replace('.', '_');
const card = document.getElementById(`perm-card-${role}-${safeKey}`);
if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
} finally {
checkbox.disabled = false;
}
}
window.togglePermission = togglePermission;
window.AdminSections = window.AdminSections || {};
window.AdminSections.permissions = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+535
View File
@@ -0,0 +1,535 @@
'use strict';
/* admin → questions section (the biggest — список + Q-modal + CSV) */
(function () {
'use strict';
let inited = false;
let allQuestions = [];
let editingQId = null;
let openQId = null;
let _topicMap = {};
let _currentType = 'single';
// window._matchPairs is exposed on window because HTML oninput uses bare `_matchPairs[i].left=this.value`
window._matchPairs = window._matchPairs || [];
let _opts = [];
let _focusedInput = null;
let _prevTimer = null;
const OPT_LETTERS = 'АБВГДЕ';
function updateCharCounter(el, cntId, max) {
const n = el.value.length;
const cnt = document.getElementById(cntId);
if (!cnt) return;
cnt.textContent = `${n} / ${max}`;
cnt.className = 'char-counter' + (n > max * 0.9 ? ' warn' : '') + (n >= max ? ' over' : '');
}
async function onQSubjectChange() {
const slug = document.getElementById('q-subject').value;
const sel = document.getElementById('q-topic');
sel.innerHTML = '<option value="">Все темы</option>';
if (slug) {
try {
const topics = await LS.getTopics(slug);
topics.forEach(t => sel.appendChild(new Option(t.name, t.id)));
} catch {}
}
load();
}
async function load() {
const subject = document.getElementById('q-subject').value;
const topic_id = document.getElementById('q-topic').value;
const sort = document.getElementById('q-sort').value;
const wrap = document.getElementById('q-list-wrap');
wrap.innerHTML = LS.skeleton(5);
try {
allQuestions = await LS.getQuestions(subject || null, topic_id || null, sort);
renderQuestions();
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка загрузки: ${esc(e.message)}</div>`;
}
}
function renderQuestions() {
const { DIFFS } = AdminCtx;
const search = document.getElementById('q-search').value.toLowerCase();
const filtered = search
? allQuestions.filter(q => q.text.toLowerCase().includes(search) || (q.topic||'').toLowerCase().includes(search))
: allQuestions;
document.getElementById('q-count').textContent = `${filtered.length} вопросов`;
if (!filtered.length) {
document.getElementById('q-list-wrap').innerHTML = '<div class="empty">Вопросов не найдено</div>';
return;
}
const wrap = document.getElementById('q-list-wrap');
wrap.innerHTML =
`<div class="q-list">${filtered.map(q => {
const diffCls = `diff-${q.difficulty}`;
const optsHtml = (q.options || []).map(o =>
`<div class="q-opt-row ${o.is_correct ? 'correct' : ''}">
<span class="q-opt-icon">${o.is_correct ? '<i data-lucide="check" style="width:13px;height:13px"></i>' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>${esc(o.text)}
</div>`).join('');
const explHtml = q.explanation
? `<div class="q-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
return `<div class="q-card" id="qcard-${q.id}">
<div class="q-card-head">
<span class="q-card-num">#${q.id}</span>
<div class="q-card-body" onclick="toggleQDetail(${q.id})">
<div class="q-card-text">${esc(q.text)}</div>
<div class="q-card-meta">
${q.subject_name ? `<span class="q-badge q-badge-subj">${esc(q.subject_name)}</span>` : ''}
${q.topic ? `<span class="q-badge q-badge-topic">${esc(q.topic)}</span>` : ''}
<span class="q-badge ${diffCls}">${DIFFS[q.difficulty]||q.difficulty}</span>
<span style="font-size:0.72rem;color:var(--text-3);background:rgba(15,23,42,0.05);padding:2px 7px;border-radius:999px">${{single:'Один',multi:'Несколько',true_false:'Верно/Неверно',short_answer:'Краткий',matching:'Сопост.'}[q.type]||q.type||'Один'}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${q.options?.length||0} вар.</span>
</div>
</div>
<div class="q-card-actions">
<button class="btn-edit-q" onclick="editQ(${q.id})">Изменить</button>
<button class="btn-dup-q" onclick="dupQ(${q.id})" title="Дублировать">⧉</button>
<button class="btn-del-q" onclick="deleteQ(${q.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>
<div class="q-card-detail" id="qdetail-${q.id}">
${optsHtml}${explHtml}
</div>
</div>`;
}).join('')}</div>`;
AdminCtx.renderMath(wrap);
if (window.lucide) lucide.createIcons();
}
function toggleQDetail(id) {
if (openQId === id) {
document.getElementById('qdetail-' + id)?.classList.remove('open');
openQId = null; return;
}
if (openQId) document.getElementById('qdetail-' + openQId)?.classList.remove('open');
document.getElementById('qdetail-' + id)?.classList.add('open');
openQId = id;
}
async function dupQ(id) {
try {
const { id: newId } = await LS.duplicateQuestion(id);
await load();
setTimeout(() => document.getElementById('qcard-' + newId)?.scrollIntoView({ behavior:'smooth', block:'center' }), 300);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function deleteQ(id) {
if (!await LS.confirm(`Удалить вопрос #${id}?`, { title: 'Удалить вопрос', confirmText: 'Удалить' })) return;
try {
await LS.deleteQuestion(id);
allQuestions = allQuestions.filter(q => q.id !== id);
renderQuestions();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── Question type ─── */
function setQType(type) {
_currentType = type;
document.querySelectorAll('[data-type]').forEach(b => b.classList.toggle('active', b.dataset.type === type));
const isMatching = type === 'matching';
const isShort = type === 'short_answer';
const showOpts = !isShort && !isMatching;
const optsHeader = document.getElementById('qf-opts-header');
if (optsHeader) optsHeader.style.display = showOpts ? '' : 'none';
document.getElementById('qf-opts').style.display = showOpts ? '' : 'none';
document.getElementById('qf-short-wrap').style.display = isShort ? '' : 'none';
document.getElementById('qf-match-wrap').style.display = isMatching ? '' : 'none';
document.getElementById('btn-add-opt').style.display = showOpts && type !== 'true_false' ? '' : 'none';
if (type === 'true_false') {
initOpts([{ text:'Верно', is_correct:false }, { text:'Неверно', is_correct:false }]);
} else if (isShort) {
_opts = [];
} else if (isMatching) {
_opts = [];
if (window._matchPairs.length === 0) window._matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
renderMatchRows();
} else {
if (_opts.length === 0 || _opts[0]?.text === 'Верно') initOpts([{},{},{},{}]);
else renderOptRows(_opts);
}
}
function renderMatchRows() {
const cont = document.getElementById('qf-match-rows');
cont.innerHTML = window._matchPairs.map((p, i) => `
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:8px" data-mi="${i}">
<input type="text" class="form-ctrl" placeholder="Элемент…" value="${esc(p.left)}"
oninput="window._matchPairs[${i}].left=this.value" style="margin:0" />
<input type="text" class="form-ctrl" placeholder="Пара к нему…" value="${esc(p.right)}"
oninput="window._matchPairs[${i}].right=this.value" style="margin:0" />
<button type="button" onclick="removeMatchPair(${i})" style="border:none;background:none;color:var(--text-3);cursor:pointer;padding:0 6px;display:flex;align-items:center" title="Удалить"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>`).join('');
if (window.lucide) lucide.createIcons();
}
function addMatchPair() {
window._matchPairs.push({left:'',right:''});
renderMatchRows();
}
function removeMatchPair(i) {
window._matchPairs.splice(i, 1);
renderMatchRows();
}
/* ─── Formula bar ─── */
document.addEventListener('focusin', e => {
if (e.target.closest && e.target.closest('#q-modal') &&
(e.target.tagName === 'TEXTAREA' || (e.target.tagName === 'INPUT' && e.target.type === 'text'))) {
_focusedInput = e.target;
}
});
function ins(latex) {
const el = _focusedInput || document.getElementById('qf-text');
if (!el) return;
const s = el.selectionStart ?? el.value.length;
const e2= el.selectionEnd ?? el.value.length;
const before = el.value.slice(0, s), after = el.value.slice(e2);
const opens = (before.match(/\\\(/g)||[]).length;
const closes = (before.match(/\\\)/g)||[]).length;
const insert = opens > closes ? latex : `\\(${latex}\\)`;
el.value = before + insert + after;
el.setSelectionRange(s + insert.length, s + insert.length);
el.focus();
updateQPreview();
}
function wrapMath() {
const el = _focusedInput || document.getElementById('qf-text');
if (!el) return;
const s = el.selectionStart, e2 = el.selectionEnd;
const sel = el.value.slice(s, e2) || 'x';
el.value = el.value.slice(0, s) + `\\(${sel}\\)` + el.value.slice(e2);
el.focus();
updateQPreview();
}
/* ─── Live preview ─── */
function updateQPreview() {
clearTimeout(_prevTimer);
_prevTimer = setTimeout(() => {
const text = (document.getElementById('qf-text').value || '').trim();
const el = document.getElementById('q-preview-text');
const wrap = document.getElementById('q-preview-wrap');
wrap.classList.toggle('hidden', !text);
if (!text) return;
el.textContent = text;
AdminCtx.renderMath(el);
}, 150);
}
// Formula bar toggle (default collapsed)
window.toggleFormulaBar = function () {
const bar = document.getElementById('formula-bar');
const btn = document.getElementById('qf-fml-toggle');
const open = bar.classList.toggle('visible');
btn.classList.toggle('open', open);
};
// Wire textarea input to preview
setTimeout(() => {
const ta = document.getElementById('qf-text');
if (ta) ta.addEventListener('input', updateQPreview);
}, 0);
/* ─── Dynamic options ─── */
function renderOptRows(opts) {
const grid = document.getElementById('qf-opts');
const isMulti = _currentType === 'multi';
grid.innerHTML = opts.map((o, i) => `
<div class="opt-row${o.is_correct ? ' opt-correct' : ''}" data-i="${i}">
<span class="opt-letter">${OPT_LETTERS[i]}</span>
${isMulti
? `<input type="checkbox" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
onchange="onCheckChange(${i}, this.checked)" />`
: `<input type="radio" name="qf-correct" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
onchange="onRadioChange(${i})" />`}
<input type="text" class="opt-input" placeholder="Вариант ${OPT_LETTERS[i]}"
value="${esc(o.text||'')}" oninput="syncOptText(${i}, this.value)" />
${opts.length > 2
? `<button type="button" class="btn-rem-opt" onclick="removeOpt(${i})" title="Удалить"></button>`
: '<span style="width:24px;flex-shrink:0"></span>'}
</div>`).join('');
document.getElementById('btn-add-opt').style.display = opts.length >= 6 ? 'none' : '';
}
function onCheckChange(idx, checked) {
_opts[idx].is_correct = checked;
document.querySelector(`#qf-opts .opt-row[data-i="${idx}"]`)?.classList.toggle('opt-correct', checked);
}
function initOpts(opts) {
_opts = opts.length ? opts.map(o => ({ text: o.text||'', is_correct: !!o.is_correct }))
: [{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false}];
renderOptRows(_opts);
}
function onRadioChange(idx) {
_opts.forEach((o, i) => o.is_correct = (i === idx));
renderOptRows(_opts);
}
function syncOptText(idx, val) { _opts[idx].text = val; }
function addOpt() {
if (_opts.length >= 6) return;
_opts.push({ text: '', is_correct: false });
renderOptRows(_opts);
const rows = document.querySelectorAll('#qf-opts .opt-row');
rows[rows.length - 1]?.querySelector('input[type=text]')?.focus();
}
function removeOpt(idx) {
if (_opts.length <= 2) return;
const wasCorrect = _opts[idx].is_correct;
_opts.splice(idx, 1);
if (wasCorrect && _opts.length > 0) _opts[0].is_correct = true;
renderOptRows(_opts);
}
/* ─── Modal ─── */
function openQModal(q = null) {
editingQId = q ? q.id : null;
document.getElementById('q-modal-title').textContent = q ? `Редактировать вопрос #${q.id}` : 'Добавить вопрос';
const textEl = document.getElementById('qf-text');
textEl.value = q?.text || '';
updateCharCounter(textEl, 'qf-text-cnt', 500);
document.getElementById('qf-explanation').value = q?.explanation || '';
document.getElementById('qf-difficulty').value = q?.difficulty ?? 2;
document.getElementById('qf-subject').value = q?.subject_slug || '';
document.getElementById('qf-topic-text').value = q?.topic || '';
document.getElementById('qf-correct-text').value = q?.correct_text || '';
document.getElementById('qf-error').textContent = '';
const imgVal = q?.image || '';
document.getElementById('qf-image').value = imgVal;
updateImagePreview(imgVal);
if (q?.type === 'matching') {
window._matchPairs = (q.options || []).map(o => ({ left: o.text, right: o.match_pair || '' }));
if (!window._matchPairs.length) window._matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
} else {
window._matchPairs = [];
}
setQType(q?.type || 'single');
if (q?.type !== 'matching') initOpts(q?.options || []);
updateQPreview();
loadQModalTopics();
document.getElementById('q-modal').classList.add('open');
setTimeout(() => textEl.focus(), 80);
}
function editQ(id) {
const q = allQuestions.find(x => x.id === id);
if (q) openQModal(q);
}
function closeQModal() {
document.getElementById('q-modal').classList.remove('open');
editingQId = null;
}
async function loadQModalTopics() {
const slug = document.getElementById('qf-subject').value;
const dl = document.getElementById('qf-topic-list');
dl.innerHTML = '';
_topicMap = {};
if (!slug) return;
try {
const topics = await LS.getTopics(slug);
topics.forEach(t => {
dl.appendChild(new Option(t.name));
_topicMap[t.name.toLowerCase()] = t.id;
});
} catch {}
}
async function saveQuestion() {
const text = document.getElementById('qf-text').value.trim();
const explanation = document.getElementById('qf-explanation').value.trim();
const difficulty = Number(document.getElementById('qf-difficulty').value);
const subject_slug = document.getElementById('qf-subject').value;
const topicText = document.getElementById('qf-topic-text').value.trim();
const type = _currentType;
const errEl = document.getElementById('qf-error');
errEl.textContent = '';
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!text) { errEl.textContent = 'Введите текст вопроса'; return; }
let options = null;
let correct_text = null;
if (type === 'short_answer') {
correct_text = document.getElementById('qf-correct-text').value.trim();
if (!correct_text) { errEl.textContent = 'Введите правильный ответ'; return; }
} else if (type === 'matching') {
document.querySelectorAll('#qf-match-rows [data-mi]').forEach((row, i) => {
const [l, r] = row.querySelectorAll('input');
if (window._matchPairs[i]) { window._matchPairs[i].left = l.value.trim(); window._matchPairs[i].right = r.value.trim(); }
});
if (window._matchPairs.length < 2) { errEl.textContent = 'Нужно минимум 2 пары'; return; }
if (window._matchPairs.some(p => !p.left || !p.right)) { errEl.textContent = 'Заполните все пары'; return; }
options = window._matchPairs.map(p => ({ text: p.left, match_pair: p.right, is_correct: 0 }));
} else {
document.querySelectorAll('#qf-opts .opt-row').forEach((row, i) => {
if (_opts[i]) _opts[i].text = row.querySelector('input[type=text]').value.trim();
});
options = _opts.map(o => ({ text: o.text, is_correct: o.is_correct }));
if (options.length < 2) { errEl.textContent = 'Нужно минимум 2 варианта ответа'; return; }
if (options.some(o => !o.text)) { errEl.textContent = 'Заполните все варианты ответов'; return; }
if (!options.some(o => o.is_correct)) { errEl.textContent = 'Отметьте правильный ответ'; return; }
}
const knownId = _topicMap[topicText.toLowerCase()];
const topic_id = knownId || null;
const topic_name = !knownId && topicText ? topicText : null;
const image = document.getElementById('qf-image').value.trim() || null;
const btn = document.getElementById('qf-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
if (editingQId) {
await LS.updateQuestion(editingQId, { text, type, correct_text, difficulty, explanation: explanation||null, topic_id, topic_name, options, image });
} else {
await LS.createQuestion({ subject_slug, topic_id, topic_name, text, type, correct_text, difficulty, explanation: explanation||null, options, image });
}
closeQModal();
load();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ── Image upload & preview ── */
function updateImagePreview(url) {
const wrap = document.getElementById('qf-image-preview');
const img = document.getElementById('qf-image-img');
if (url) { img.src = url; wrap.classList.add('visible'); }
else { wrap.classList.remove('visible'); img.src = ''; }
}
function clearQuestionImage() {
document.getElementById('qf-image').value = '';
updateImagePreview('');
}
async function handleImageFileSelect(input) {
const file = input.files[0];
if (!file) return;
input.value = '';
const btn = document.getElementById('btn-img-upload');
const lbl = document.getElementById('btn-img-upload-lbl');
btn.disabled = true;
lbl.textContent = 'Загрузка…';
try {
const fd = new FormData();
fd.append('file', file);
fd.append('title', 'Question image: ' + file.name);
fd.append('is_public', '1');
const res = await fetch('/api/files', {
method: 'POST',
headers: { Authorization: 'Bearer ' + localStorage.getItem('ls_token') },
body: fd
});
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
const { id } = await res.json();
const url = `/api/files/${id}/download`;
document.getElementById('qf-image').value = url;
updateImagePreview(url);
} catch (e) {
document.getElementById('qf-error').textContent = 'Ошибка загрузки: ' + e.message;
} finally {
btn.disabled = false;
lbl.textContent = 'Загрузить';
}
}
document.addEventListener('DOMContentLoaded', () => {
const imgInput = document.getElementById('qf-image');
if (imgInput) imgInput.addEventListener('input', e => updateImagePreview(e.target.value.trim()));
});
/* ── CSV Import ── */
async function importCSVFile(input) {
const file = input.files[0];
if (!file) return;
input.value = '';
const fd = new FormData();
fd.append('file', file);
const btn = document.querySelector('[onclick="document.getElementById(\'csv-file-input\').click()"]');
if (btn) { btn.disabled = true; btn.textContent = 'Импорт…'; }
try {
const { imported, errors } = await LS.importQuestions(fd);
LS.toast(`Импортировано: ${imported} вопросов${errors.length ? ` (${errors.length} ошибок)` : ''}`, imported > 0 ? 'success' : 'warn', 5000);
load();
} catch (e) {
LS.toast('Ошибка импорта: ' + e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i data-lucide="upload" style="width:14px;height:14px;vertical-align:-2px"></i> Импорт CSV'; if(window.lucide)lucide.createIcons(); }
}
}
function downloadCSVTemplate(e) {
e.preventDefault();
const header = 'subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year';
const example = [
'bio;Клетки;Что является «электростанцией» клетки?;2;single;Митохондрия;1;Рибосома;0;Лизосома;0;Ядро;0;;Митохондрии синтезируют АТФ;2024',
'bio;Клетки;Какие органоиды участвуют в синтезе белка?;2;multi;Рибосома;1;Митохондрия;0;Эндоплазматическая сеть;1;Лизосома;0;;',
'chem;Кислоты;Формула серной кислоты;1;short_answer;;;;;;;;H2SO4;;',
].join('\n');
const blob = new Blob(['' + header + '\n' + example], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'questions_template.csv';
a.click();
}
// Expose handlers used by onclick (HTML or sibling sections)
window.onQSubjectChange = onQSubjectChange;
window.loadQuestions = load;
window.renderQuestions = renderQuestions;
window.toggleQDetail = toggleQDetail;
window.dupQ = dupQ;
window.deleteQ = deleteQ;
window.setQType = setQType;
window.addMatchPair = addMatchPair;
window.removeMatchPair = removeMatchPair;
window.ins = ins;
window.wrapMath = wrapMath;
window.updateQPreview = updateQPreview;
window.onCheckChange = onCheckChange;
window.onRadioChange = onRadioChange;
window.syncOptText = syncOptText;
window.addOpt = addOpt;
window.removeOpt = removeOpt;
window.openQModal = openQModal;
window.editQ = editQ;
window.closeQModal = closeQModal;
window.loadQModalTopics = loadQModalTopics;
window.saveQuestion = saveQuestion;
window.updateImagePreview = updateImagePreview;
window.clearQuestionImage = clearQuestionImage;
window.handleImageFileSelect = handleImageFileSelect;
window.importCSVFile = importCSVFile;
window.downloadCSVTemplate = downloadCSVTemplate;
window.AdminSections = window.AdminSections || {};
window.AdminSections.questions = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
openModal: openQModal,
loadModalTopics: loadQModalTopics,
};
})();
+159
View File
@@ -0,0 +1,159 @@
'use strict';
/* admin → sessions section: sessions timeline + drawer detail */
(function () {
'use strict';
let inited = false;
let allSessions = [];
let openDrawerId = null;
async function load() {
const subject = document.getElementById('t-subject').value;
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
openDrawerId = null;
try {
allSessions = await LS.adminGetSessions({ subject: subject || undefined });
renderSessions();
} catch (e) {
document.getElementById('t-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function sessPctRing(pct) {
const { pctClass } = AdminCtx;
const pc = pctClass(pct);
const colorMap = {'pct-hi':'var(--green)','pct-mid':'var(--amber)','pct-lo':'var(--pink)'};
const color = colorMap[pc] || 'var(--text-3)';
const circ = 106.8;
const dash = (pct / 100 * circ).toFixed(1);
return `<svg class="sess-tl-ring" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="17" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
<circle cx="24" cy="24" r="17" fill="none" stroke="${color}" stroke-width="4"
stroke-dasharray="${dash} ${circ}" stroke-dashoffset="26.7" stroke-linecap="round"
transform="rotate(-90 24 24)"/>
<text x="24" y="28" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${pct}%</text>
</svg>`;
}
function renderSessions() {
const { MODES, fmtDate, fmtTime } = AdminCtx;
const modeF = document.getElementById('t-mode').value;
const searchF = document.getElementById('t-search').value.toLowerCase();
const filtered = allSessions.filter(s => {
if (modeF && s.mode !== modeF) return false;
if (searchF && !s.user_name.toLowerCase().includes(searchF) && !s.user_email.toLowerCase().includes(searchF)) return false;
return true;
});
document.getElementById('t-count').textContent = `${filtered.length} тестов`;
if (!filtered.length) {
document.getElementById('t-body').innerHTML = '<div class="empty">Нет тестов</div>';
return;
}
const groups = {};
filtered.forEach(s => {
const key = fmtDate(s.started_at);
(groups[key] = groups[key] || []).push(s);
});
document.getElementById('t-body').innerHTML = Object.entries(groups).map(([date, sessions]) =>
`<div class="sess-tl-day">${date}</div>
<div class="sess-tl-wrap">${sessions.map(s => {
const ring = s.percent !== null
? sessPctRing(s.percent)
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.85rem;font-weight:800;color:var(--text-3)">—</div>`;
return `<div class="sess-tl-item" id="trow-${s.id}" onclick="toggleDrawer(${s.id})">
${ring}
<div class="sess-tl-user">
<div class="sess-tl-name">${esc(s.user_name)}</div>
<div class="sess-tl-meta">${esc(s.subject_name||'?')} · <span class="mode-badge mode-${s.mode}">${MODES[s.mode]||s.mode}</span></div>
</div>
<div class="sess-tl-score">${s.score??'—'} / ${s.total}</div>
<div class="sess-tl-time">${fmtTime(s.duration_sec)}</div>
</div>
<div class="sess-tl-drawer" id="tdrawer-${s.id}">
<div class="sess-drawer" id="drawer-${s.id}">
<div class="sess-drawer-inner" id="drawer-inner-${s.id}"><div class="spinner"></div></div>
</div>
</div>`;
}).join('')}</div>`
).join('');
}
async function toggleDrawer(id) {
const drawerEl = document.getElementById('tdrawer-' + id);
const drawer = document.getElementById('drawer-' + id);
const trow = document.getElementById('trow-' + id);
if (openDrawerId && openDrawerId !== id) {
document.getElementById('tdrawer-' + openDrawerId)?.classList.remove('open');
document.getElementById('drawer-' + openDrawerId)?.classList.remove('open');
document.getElementById('trow-' + openDrawerId)?.classList.remove('open');
}
if (openDrawerId === id) {
drawerEl.classList.remove('open'); drawer.classList.remove('open'); trow.classList.remove('open');
openDrawerId = null; return;
}
openDrawerId = id; trow.classList.add('open');
drawerEl.classList.add('open');
requestAnimationFrame(() => drawer.classList.add('open'));
const inner = document.getElementById('drawer-inner-' + id);
if (inner.dataset.loaded) return;
inner.dataset.loaded = '1';
try {
const d = await LS.adminGetSessionDetail(id);
renderDrawer(inner, d);
} catch (e) { inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; }
}
function renderDrawer(el, d) {
const { MODES, pctClass, fmtDate, fmtTime, renderMath } = AdminCtx;
const pct = d.score !== null && d.total ? Math.round((d.score/d.total)*100) : null;
const pc = pctClass(pct);
const correct = d.questions.filter(q => q.is_correct).length;
const wrong = d.questions.filter(q => !q.is_correct && q.chosen_option_id).length;
const skipped = d.questions.filter(q => !q.chosen_option_id).length;
const qHtml = d.questions.map((q,i) => {
const status = !q.chosen_option_id ? 'skipped' : q.is_correct ? 'correct' : 'wrong';
const badgeTxt = { correct:'Верно', wrong:'Неверно', skipped:'Пропущено' }[status];
const opts = q.options.map(o => {
const isCor = o.is_correct, isCho = o.id === q.chosen_option_id;
let cls='', icon='<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
if (isCor) { cls='correct-opt'; icon='<i data-lucide="check" style="width:13px;height:13px"></i>'; }
else if (isCho && !isCor) { cls='chosen-wrong'; icon='<i data-lucide="x" style="width:13px;height:13px"></i>'; }
return `<div class="qb-opt ${cls}"><span class="qb-opt-icon">${icon}</span>${esc(o.text)}</div>`;
}).join('');
const expl = q.explanation ? `<div class="qb-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
return `<div class="qb-item ${status}">
<div class="qb-header"><span class="qb-qnum">Вопрос ${i+1}</span><span class="qb-badge ${status}">${badgeTxt}</span><span class="qb-time">${q.time_spent_sec?q.time_spent_sec+' сек':''}</span></div>
<div class="qb-text">${esc(q.text)}</div>
<div class="qb-opts">${opts}</div>${expl}
</div>`;
}).join('');
el.innerHTML = `
<div class="drawer-header">
<div>
<div style="font-family:'Unbounded',sans-serif;font-weight:800;font-size:0.95rem">${esc(d.user_name)}</div>
<div class="drawer-meta">${esc(d.user_email)} · ${d.subject_name||'?'} · ${MODES[d.mode]||d.mode} · ${fmtDate(d.started_at)}</div>
</div>
<div class="drawer-score ${pc}">${pct !== null ? pct+'%' : '—'}</div>
<div style="display:flex;gap:20px;margin-left:auto;text-align:center">
<div><div style="font-family:'Unbounded',sans-serif;color:var(--green);font-weight:700">${correct}</div><div style="font-size:0.72rem;color:var(--text-3)">Верно</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--pink);font-weight:700">${wrong}</div><div style="font-size:0.72rem;color:var(--text-3)">Неверно</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-3);font-weight:700">${skipped}</div><div style="font-size:0.72rem;color:var(--text-3)">Пропущено</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-2);font-weight:700">${fmtTime(d.duration_sec)}</div><div style="font-size:0.72rem;color:var(--text-3)">Время</div></div>
</div>
</div>
<div class="qb-list">${qHtml||'<div class="empty">Вопросы не найдены</div>'}</div>`;
renderMath(el);
if (window.lucide) lucide.createIcons();
}
// Expose handlers
window.loadSessions = load;
window.renderSessions = renderSessions;
window.toggleDrawer = toggleDrawer;
window.AdminSections = window.AdminSections || {};
window.AdminSections.sessions = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+207
View File
@@ -0,0 +1,207 @@
'use strict';
/* admin → shop section: items + purchases + award coins */
(function () {
'use strict';
let inited = false;
let _shopItems = [];
let _shopEditId = null;
let _shopSaving = false;
let _shopSearchTimer = null;
let _coinsAwarding = false;
async function load() {
try {
const [stats, items] = await Promise.all([
LS.adminShopStats(),
LS.adminShopGetItems()
]);
const topName = stats.topItems?.[0]?.name || '—';
document.getElementById('shop-stats-grid').innerHTML = `
<div class="stat-card" style="--stat-top:var(--violet)">
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="shopping-bag" class="stat-icon"></i></div>
<div class="stat-val violet">${stats.activeItems}/${stats.totalItems}</div>
<div class="stat-label">Товаров</div>
</div>
<div class="stat-card" style="--stat-top:var(--cyan)">
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="receipt" class="stat-icon"></i></div>
<div class="stat-val cyan">${stats.totalPurchases}</div>
<div class="stat-label">Покупок</div>
</div>
<div class="stat-card" style="--stat-top:var(--green)">
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="coins" class="stat-icon"></i></div>
<div class="stat-val green">${stats.totalCoinsInCirculation}</div>
<div class="stat-label">Монет в обороте</div>
</div>
<div class="stat-card" style="--stat-top:var(--amber, #FFB347)">
<div class="stat-card-icon" style="background:rgba(255,179,71,0.1)"><i data-lucide="star" class="stat-icon"></i></div>
<div class="stat-val" style="color:var(--amber, #FFB347);font-size:1.1rem">${esc(topName)}</div>
<div class="stat-label">Топ товар</div>
</div>`;
_shopItems = items;
renderShopItems();
if (window.lucide) lucide.createIcons();
} catch(e) {
document.getElementById('shop-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderShopItems() {
const body = document.getElementById('shop-items-body');
if (!_shopItems.length) { body.innerHTML = '<tr><td colspan="7" class="empty">Нет товаров</td></tr>'; return; }
const typeLabels = { frame:'Рамка', title:'Титул', theme:'Тема', effect:'Эффект' };
body.innerHTML = _shopItems.map(it => `<tr>
<td>${it.id}</td>
<td><strong>${esc(it.name)}</strong></td>
<td><span class="mode-badge mode-practice">${typeLabels[it.type] || esc(it.type)}</span></td>
<td>${it.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
<td>${it.sold_count || 0}</td>
<td>
<label class="adm-toggle">
<input type="checkbox" ${it.is_active ? 'checked' : ''} onchange="shopAdminToggleActive(${it.id}, this.checked)" />
<span class="track"></span><span class="thumb"></span>
</label>
</td>
<td>
<button class="btn-edit-q" onclick="shopAdminEditItem(${it.id})">Ред.</button>
<button class="btn-del-q" onclick="shopAdminDeleteItem(${it.id})">Удалить</button>
</td>
</tr>`).join('');
if (window.lucide) lucide.createIcons();
}
function shopAdminCreateItem() {
_shopEditId = null;
document.getElementById('shop-form-title').textContent = 'Добавить товар';
document.getElementById('shop-f-name').value = '';
document.getElementById('shop-f-type').value = 'frame';
document.getElementById('shop-f-price').value = '100';
document.getElementById('shop-f-desc').value = '';
document.getElementById('shop-f-icon').value = '';
document.getElementById('shop-f-data').value = '';
document.getElementById('shop-f-active').checked = true;
document.getElementById('shop-item-form').style.display = '';
}
function shopAdminEditItem(id) {
const it = _shopItems.find(i => i.id === id);
if (!it) return;
_shopEditId = id;
document.getElementById('shop-form-title').textContent = 'Редактировать товар #' + id;
document.getElementById('shop-f-name').value = it.name || '';
document.getElementById('shop-f-type').value = it.type || 'frame';
document.getElementById('shop-f-price').value = it.price ?? 100;
document.getElementById('shop-f-desc').value = it.description || '';
document.getElementById('shop-f-icon').value = it.icon || '';
document.getElementById('shop-f-data').value = it.data ? (typeof it.data === 'string' ? it.data : JSON.stringify(it.data)) : '';
document.getElementById('shop-f-active').checked = !!it.is_active;
document.getElementById('shop-item-form').style.display = '';
}
function shopAdminCancelForm() {
document.getElementById('shop-item-form').style.display = 'none';
_shopEditId = null;
}
async function shopAdminSaveItem() {
if (_shopSaving) return;
_shopSaving = true;
const data = {
name: document.getElementById('shop-f-name').value.trim(),
type: document.getElementById('shop-f-type').value,
price: parseInt(document.getElementById('shop-f-price').value) || 0,
description: document.getElementById('shop-f-desc').value.trim(),
icon: document.getElementById('shop-f-icon').value.trim(),
data: document.getElementById('shop-f-data').value.trim() || null,
is_active: document.getElementById('shop-f-active').checked ? 1 : 0
};
if (!data.name) { LS.toast('Введите название', 'error'); _shopSaving = false; return; }
try {
if (_shopEditId) {
await LS.adminShopUpdateItem(_shopEditId, data);
LS.toast('Товар обновлён', 'success');
} else {
await LS.adminShopCreateItem(data);
LS.toast('Товар создан', 'success');
}
shopAdminCancelForm();
inited = false;
await load();
inited = true;
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { _shopSaving = false; }
}
async function shopAdminDeleteItem(id) {
if (!await LS.confirm('Все покупки этого товара будут удалены.', { title: 'Удалить товар?', confirmText: 'Удалить', danger: true })) return;
try {
await LS.adminShopDeleteItem(id);
LS.toast('Товар удалён', 'success');
inited = false;
await load();
inited = true;
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function shopAdminToggleActive(id, active) {
try {
await LS.adminShopUpdateItem(id, { is_active: active ? 1 : 0 });
LS.toast(active ? 'Товар активирован' : 'Товар деактивирован', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function shopSearchUser(q) {
clearTimeout(_shopSearchTimer);
const box = document.getElementById('shop-award-results');
if (q.length < 2) { box.classList.remove('open'); return; }
_shopSearchTimer = setTimeout(async () => {
try {
const r = await LS.adminGetUsers({ q, limit: 8 });
box.innerHTML = (r.users || []).map(u => `<div class="us-item" onclick="shopPickUser(${u.id}, '${esc(u.name || u.email)}')">
<span>${esc(u.name || u.email)}</span><span class="us-role">${u.role}</span>
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
box.classList.add('open');
} catch(e) { box.classList.remove('open'); }
}, 300);
}
function shopPickUser(id, name) {
document.getElementById('shop-award-uid').value = id;
document.getElementById('shop-award-user').value = name;
document.getElementById('shop-award-results').classList.remove('open');
}
async function shopAdminAwardCoins() {
if (_coinsAwarding) return;
const userId = parseInt(document.getElementById('shop-award-uid').value);
const amount = parseInt(document.getElementById('shop-award-amount').value);
const reason = document.getElementById('shop-award-reason').value.trim();
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
if (!amount || amount <= 0) { LS.toast('Введите количество монет', 'error'); return; }
_coinsAwarding = true;
try {
const r = await LS.adminShopAwardCoins({ userId, amount, reason });
LS.toast(`Начислено ${amount} монет. Баланс: ${r.coins}`, 'success');
document.getElementById('shop-award-uid').value = '';
document.getElementById('shop-award-user').value = '';
document.getElementById('shop-award-reason').value = '';
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { _coinsAwarding = false; }
}
// Expose onclick handlers
window.shopAdminCreateItem = shopAdminCreateItem;
window.shopAdminEditItem = shopAdminEditItem;
window.shopAdminCancelForm = shopAdminCancelForm;
window.shopAdminSaveItem = shopAdminSaveItem;
window.shopAdminDeleteItem = shopAdminDeleteItem;
window.shopAdminToggleActive = shopAdminToggleActive;
window.shopSearchUser = shopSearchUser;
window.shopPickUser = shopPickUser;
window.shopAdminAwardCoins = shopAdminAwardCoins;
window.AdminSections = window.AdminSections || {};
window.AdminSections.shop = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+118
View File
@@ -0,0 +1,118 @@
'use strict';
/* admin → sims (simulations) section */
(function () {
'use strict';
let inited = false;
// Full list of available (non-null id) sims mirrored from /lab
const ADMIN_SIMS = [
{ id: 'graph', cat: 'Математика', title: 'График функции' },
{ id: 'graphtransform', cat: 'Математика', title: 'Трансформации графиков' },
{ id: 'geometry', cat: 'Математика', title: 'Планиметрия' },
{ id: 'triangle', cat: 'Математика', title: 'Геометрия треугольника' },
{ id: 'quadratic', cat: 'Математика', title: 'Корни квадратного уравнения' },
{ id: 'stereo', cat: 'Математика', title: 'Стереометрия 3D' },
{ id: 'probability', cat: 'Математика', title: 'Теория вероятностей' },
{ id: 'trigcircle', cat: 'Математика', title: 'Тригонометрическая окружность' },
{ id: 'normaldist', cat: 'Математика', title: 'Нормальное распределение' },
{ id: 'projectile', cat: 'Физика', title: 'Бросок тела' },
{ id: 'pendulum', cat: 'Физика', title: 'Маятник' },
{ id: 'collision', cat: 'Физика', title: 'Столкновение шаров' },
{ id: 'magnetic', cat: 'Физика', title: 'Магнитное поле токов' },
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
{ id: 'coulomb', cat: 'Физика', title: 'Закон Кулона' },
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
{ id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' },
{ id: 'refraction', cat: 'Физика', title: 'Преломление света' },
{ id: 'mirrors', cat: 'Физика', title: 'Зеркала' },
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
{ id: 'electrolysis', cat: 'Химия', title: 'Электролиз' },
{ id: 'bohratom', cat: 'Химия', title: 'Атом Бора' },
{ id: 'orbitals', cat: 'Химия', title: 'Молекулярные орбитали' },
{ id: 'titration', cat: 'Химия', title: 'pH и кривая титрования' },
{ id: 'chemsandbox', cat: 'Химия', title: 'Химическая песочница' },
{ id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' },
{ id: 'celldivision', cat: 'Биология', title: 'Деление клетки' },
{ id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' },
{ id: 'angrybirds', cat: 'Игры', title: 'Angry Birds Physics' },
];
let _simsSettings = { module_disabled: false, disabled_ids: [] };
async function load() {
try {
const data = await LS.api('/api/settings/sims');
_simsSettings = data;
_render();
} catch(e) { LS.toast('Ошибка загрузки настроек: ' + e.message, 'error'); }
}
function _render() {
// master toggle
const masterChk = document.getElementById('sims-master-chk');
if (masterChk) masterChk.checked = !_simsSettings.module_disabled;
// per-sim cards
const grid = document.getElementById('sims-grid');
const dis = new Set(_simsSettings.disabled_ids || []);
// group by category
const byCat = {};
ADMIN_SIMS.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
let html = '';
Object.entries(byCat).forEach(([cat, sims]) => {
html += `<div style="grid-column:1/-1;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-top:12px;margin-bottom:2px">${esc(cat)}</div>`;
sims.forEach(s => {
const enabled = !dis.has(s.id);
html += `<div class="perm-card${enabled ? ' enabled' : ''}" id="simcard-${s.id}">
<div class="perm-info">
<div class="perm-label">${esc(s.title)}</div>
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}</div>
</div>
<label class="perm-toggle" title="${enabled ? 'Отключить' : 'Включить'}">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="simToggleOne('${s.id}', this.checked)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>`;
});
});
grid.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
async function simsMasterToggle(checked) {
try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) });
_simsSettings.module_disabled = !checked;
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function simToggleOne(simId, enabled) {
const dis = new Set(_simsSettings.disabled_ids || []);
if (enabled) dis.delete(simId); else dis.add(simId);
const disabled_ids = [...dis];
try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ disabled_ids }) });
_simsSettings.disabled_ids = disabled_ids;
const card = document.getElementById('simcard-' + simId);
if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? `«${simId}» включена` : `«${simId}» отключена`, enabled ? 'success' : 'warning');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.simsMasterToggle = simsMasterToggle;
window.simToggleOne = simToggleOne;
window.AdminSections = window.AdminSections || {};
window.AdminSections.sims = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+50
View File
@@ -0,0 +1,50 @@
'use strict';
/* admin → stats section */
(function () {
'use strict';
let inited = false;
async function load() {
try {
const s = await LS.adminGetStats();
document.getElementById('stats-grid').innerHTML = `
<div class="stat-card" style="--stat-top:var(--violet)">
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="users" class="stat-icon"></i></div>
<div class="stat-val violet">${s.totalUsers}</div>
<div class="stat-label">Пользователей</div>
</div>
<div class="stat-card" style="--stat-top:var(--cyan)">
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="file-text" class="stat-icon"></i></div>
<div class="stat-val cyan">${s.totalTests}</div>
<div class="stat-label">Тестов пройдено</div>
</div>
<div class="stat-card" style="--stat-top:var(--green)">
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="target" class="stat-icon"></i></div>
<div class="stat-val green">${s.avgScore ?? '—'}%</div>
<div class="stat-label">Средний результат</div>
</div>`;
if (window.lucide) lucide.createIcons();
const subjEl = document.getElementById('subj-stats');
if (!s.bySubject?.length) { subjEl.innerHTML = '<div class="empty">Нет данных</div>'; return; }
subjEl.innerHTML = s.bySubject.map(b => {
const pct = b.avg_pct ?? 0;
const barColor = pct >= 75 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--pink)';
return `<div class="subj-stat">
<div><div class="subj-stat-name">${esc(b.name)}</div><div class="subj-stat-info">${b.tests} тестов</div></div>
<div>
<div class="subj-stat-pct">${b.avg_pct ?? '—'}%</div>
<div style="width:60px;height:3px;background:rgba(15,23,42,0.06);border-radius:99px;margin-top:5px;overflow:hidden"><div style="width:${pct}%;height:100%;background:${barColor};border-radius:99px"></div></div>
</div>
</div>`;
}).join('');
} catch (e) {
LS.state.error(document.getElementById('stats-grid'), e, load);
}
}
window.AdminSections = window.AdminSections || {};
window.AdminSections.stats = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+338
View File
@@ -0,0 +1,338 @@
'use strict';
/* admin → subjects (доступные тесты) section */
(function () {
'use strict';
let inited = false;
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' };
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
// кэш тестов по предмету для селектора
const _scTests = {};
async function loadScTests(slug) {
if (_scTests[slug]) return _scTests[slug];
const tests = await LS.getTests(slug);
_scTests[slug] = tests;
return tests;
}
function setSrcMode(slug, src) {
const rndBtn = document.getElementById(`sc-src-rnd-${slug}`);
const fixBtn = document.getElementById(`sc-src-fix-${slug}`);
const pick = document.getElementById(`sc-test-pick-${slug}`);
const cntWrap = document.getElementById(`sc-count-wrap-${slug}`);
rndBtn.classList.toggle('active', src === 'random');
fixBtn.classList.toggle('active', src === 'fixed');
pick.classList.toggle('open', src === 'fixed');
cntWrap.style.display = src === 'random' ? '' : 'none';
if (src === 'fixed') {
loadAndRenderTestPick(slug);
} else {
const dr = document.getElementById(`sc-qdr-${slug}`);
if (dr) { dr.style.display = 'none'; }
}
}
async function loadAndRenderTestPick(slug) {
const sel = document.getElementById(`sc-test-sel-${slug}`);
if (sel.dataset.loaded) return;
sel.innerHTML = '<option value="">Загрузка…</option>';
try {
const tests = await loadScTests(slug);
const cur = document.getElementById(`sc-card-${slug}`)?.dataset.testId || '';
sel.innerHTML = `<option value="">— случайные вопросы —</option>` +
tests.map(t => `<option value="${t.id}"${String(t.id) === cur ? ' selected' : ''}>${esc(t.title)} (${t.question_count ?? '?'} вопр.)</option>`).join('');
sel.dataset.loaded = '1';
} catch(e) {
sel.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
async function load() {
const wrap = document.getElementById('subj-config-list');
wrap.innerHTML = LS.skeleton(4);
try {
const subjects = await LS.getSubjects();
wrap.innerHTML = subjects.map(s => {
const hasFix = !!s.default_test_id;
const color = SC_COLORS[s.slug] || '#9B5DE5';
const mode = s.default_mode || 'exam';
const count = s.default_count || 25;
const srcLabel = hasFix ? 'Фикс. тест' : `${count} вопросов`;
return `
<div class="sc-card" id="sc-card-${s.slug}" data-test-id="${s.default_test_id || ''}">
<div class="sc-row-top" onclick="toggleScCard('${s.slug}')">
<div class="sc-icon" style="background:${color}"><i data-lucide="${SC_ICONS[s.slug]||'book'}"></i></div>
<div class="sc-info">
<div class="sc-name">${esc(s.name)}</div>
<div class="sc-summary" id="sc-sum-${s.slug}">
<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span>
<span class="sc-tag">${srcLabel}</span>
<span class="sc-qcount">${s.question_count ?? 0} в базе</span>
</div>
</div>
<i data-lucide="chevron-down" class="sc-chevron"></i>
</div>
<div class="sc-body">
<!-- Quick presets -->
<div class="sc-presets">
<button class="sc-preset${mode==='exam'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',25)">Экзамен 25</button>
<button class="sc-preset${mode==='exam'&&count===40&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',40)">Экзамен 40</button>
<button class="sc-preset${mode==='practice'&&count===15&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',15)">Практика 15</button>
<button class="sc-preset${mode==='practice'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',25)">Практика 25</button>
</div>
<!-- Detailed fields -->
<div class="sc-fields">
<div class="sc-field">
<span class="sc-label">Режим</span>
<select class="sc-select" id="sc-mode-${s.slug}">
${Object.entries(SC_MODES).map(([v, l]) =>
`<option value="${v}"${mode === v ? ' selected' : ''}>${l}</option>`
).join('')}
</select>
</div>
<div class="sc-field">
<span class="sc-label">Источник</span>
<div class="sc-src-toggle">
<button class="sc-src-btn${hasFix ? '' : ' active'}" id="sc-src-rnd-${s.slug}" onclick="setSrcMode('${s.slug}','random')">Случайные</button>
<button class="sc-src-btn${hasFix ? ' active' : ''}" id="sc-src-fix-${s.slug}" onclick="setSrcMode('${s.slug}','fixed')">Из теста</button>
</div>
</div>
<div class="sc-field" id="sc-count-wrap-${s.slug}" style="${hasFix ? 'display:none' : ''}">
<span class="sc-label">Вопросов</span>
<input class="sc-input" type="number" id="sc-count-${s.slug}" min="5" max="100" value="${count}" />
</div>
<div class="sc-test-pick${hasFix ? ' open' : ''}" id="sc-test-pick-${s.slug}">
<div class="sc-field">
<span class="sc-label">Тест</span>
<select class="sc-select" id="sc-test-sel-${s.slug}" onchange="onScTestChange('${s.slug}')">
<option value="${s.default_test_id || ''}" selected>Загрузка...</option>
</select>
</div>
<button class="sc-save-add" id="sc-qdr-btn-${s.slug}" style="display:${hasFix?'':'none'};align-self:flex-start"
onclick="toggleScDrawer('${s.slug}')"><i data-lucide="list" style="width:13px;height:13px;vertical-align:-2px"></i> Вопросы</button>
</div>
</div>
<!-- Footer -->
<div class="sc-footer">
<button class="sc-save" id="sc-save-btn-${s.slug}" onclick="saveSubjectConfig('${s.slug}')">Сохранить</button>
<button class="sc-save-add" onclick="goAddQuestion('${s.slug}')"><i data-lucide="plus" style="width:13px;height:13px;vertical-align:-2px"></i> Вопрос</button>
</div>
</div>
</div>
<div id="sc-qdr-${s.slug}" style="display:none;border-top:1px solid var(--border);padding:20px 24px;background:rgba(238,242,255,0.5)">
<div id="sc-qdr-inner-${s.slug}"></div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
subjects.filter(s => s.default_test_id).forEach(s => {
loadAndRenderTestPick(s.slug);
const btn = document.getElementById(`sc-qdr-btn-${s.slug}`);
if (btn) btn.style.display = '';
});
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function toggleScCard(slug) {
const card = document.getElementById('sc-card-' + slug);
if (!card) return;
const wasOpen = card.classList.contains('open');
document.querySelectorAll('.sc-card.open').forEach(c => c.classList.remove('open'));
if (!wasOpen) {
card.classList.add('open');
if (window.lucide) lucide.createIcons({ nodes: [card] });
}
}
function applyPreset(slug, mode, count) {
document.getElementById('sc-mode-' + slug).value = mode;
document.getElementById('sc-count-' + slug).value = count;
setSrcMode(slug, 'random');
const card = document.getElementById('sc-card-' + slug);
card.querySelectorAll('.sc-preset').forEach(p => p.classList.remove('active'));
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
card.querySelectorAll('.sc-preset').forEach(p => {
const txt = p.textContent.trim();
const mLabel = SC_MODES[mode];
if (txt === mLabel + ' ' + count && !isFix) p.classList.add('active');
});
saveSubjectConfig(slug);
}
function updateScSummary(slug) {
const el = document.getElementById('sc-sum-' + slug);
if (!el) return;
const mode = document.getElementById('sc-mode-' + slug).value;
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
const count = document.getElementById('sc-count-' + slug).value;
const srcLabel = isFix ? 'Фикс. тест' : count + ' вопросов';
el.innerHTML = `<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span><span class="sc-tag">${srcLabel}</span>`;
}
async function saveSubjectConfig(slug) {
const btn = document.getElementById(`sc-save-btn-${slug}`);
const mode = document.getElementById(`sc-mode-${slug}`).value;
const isFix = document.getElementById(`sc-src-fix-${slug}`).classList.contains('active');
const count = Number(document.getElementById(`sc-count-${slug}`)?.value || 25);
const testId = isFix ? (document.getElementById(`sc-test-sel-${slug}`).value || null) : null;
if (btn) { btn.disabled = true; btn.textContent = '...'; }
const payload = { default_mode: mode, default_count: count, default_test_id: testId ? Number(testId) : null };
try {
await LS.updateSubject(slug, payload);
document.getElementById(`sc-card-${slug}`).dataset.testId = testId || '';
if (isFix) document.getElementById(`sc-test-sel-${slug}`).dataset.loaded = '';
updateScSummary(slug);
if (btn) { btn.classList.add('saved'); btn.textContent = 'Сохранено'; }
setTimeout(() => { if (btn) { btn.classList.remove('saved'); btn.textContent = 'Сохранить'; btn.disabled = false; } }, 1500);
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Сохранить'; }
}
}
function onScTestChange(slug) {
const tid = document.getElementById(`sc-test-sel-${slug}`).value;
const btn = document.getElementById(`sc-qdr-btn-${slug}`);
btn.style.display = tid ? '' : 'none';
const dr = document.getElementById(`sc-qdr-${slug}`);
dr.style.display = 'none';
document.getElementById(`sc-qdr-inner-${slug}`).innerHTML = '';
}
async function toggleScDrawer(slug) {
const dr = document.getElementById(`sc-qdr-${slug}`);
const tid = Number(document.getElementById(`sc-test-sel-${slug}`).value);
if (!tid) return;
if (dr.style.display !== 'none') { dr.style.display = 'none'; return; }
dr.style.display = '';
await renderScDrawer(slug, tid);
}
const _scCache = {}; // tid → { test, subjectQs }
async function renderScDrawer(slug, tid) {
const inner = document.getElementById(`sc-qdr-inner-${slug}`);
inner.innerHTML = LS.skeleton(3, 'row');
try {
const [t, subjectQs] = await Promise.all([
LS.getTest(tid),
LS.getQuestions(slug, null, 'date_asc').catch(() => []),
]);
_scCache[tid] = { test: t, subjectQs };
inner.innerHTML = `
<div class="tst-cols">
<div>
<div class="tst-panel-title">Вопросы в тесте (<span id="sc-qcnt-${tid}">${t.questions.length}</span>)</div>
<div class="tst-q-list" id="sc-ql-${tid}">${renderScQList(t.questions, tid, slug)}</div>
</div>
<div>
<div class="tst-panel-title">Добавить из базы</div>
<input class="tst-search" placeholder="Поиск…" oninput="filterScPicker(${tid},'${slug}',this.value)" />
<div class="tst-q-list" id="sc-pick-${tid}">${renderScPicker(subjectQs, new Set(t.questions.map(q=>q.id)), tid, slug)}</div>
</div>
</div>`;
AdminCtx.renderMath(inner);
} catch(e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderScQList(questions, tid, slug) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Пусто. Добавьте вопросы справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>';
return questions.map((q,i) => `
<div class="tst-q-item" id="sc-qi-${tid}-${q.id}">
<span class="tst-q-num">${i+1}.</span>
<div class="tst-q-body">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
${qTypeBadge(q.type)}
${qOptsPreview(q)}
</div>
</div>
<button class="btn-tst-rem" onclick="scRemoveQ(${tid},'${slug}',${q.id})" title="Убрать"></button>
</div>`).join('');
}
function renderScPicker(questions, inIds, tid, slug) {
const { DIFF_LABELS, qTypeBadge } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
return questions.map(q => {
const added = inIds.has(q.id);
return `
<div class="tst-q-item" id="sc-pick-item-${tid}-${q.id}" style="${added?'opacity:0.4;pointer-events:none':''}">
<div class="tst-q-body" style="flex:1">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
${qTypeBadge(q.type)}
${q.topic ? `<span class="tst-q-badge" style="background:rgba(6,214,224,0.1);color:#05aab3">${esc(q.topic)}</span>` : ''}
</div>
</div>
<button class="btn-tst-add" id="sc-add-btn-${tid}-${q.id}" onclick="scAddQ(${tid},'${slug}',${q.id},this)" title="Добавить">${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+' }</button>
</div>`;
}).join('');
}
function filterScPicker(tid, slug, q) {
const cache = _scCache[tid];
if (!cache) return;
const lq = q.toLowerCase();
const filtered = lq.length < 1
? cache.subjectQs
: cache.subjectQs.filter(x => x.text.toLowerCase().includes(lq) || (x.topic||'').toLowerCase().includes(lq));
const inIds = new Set(cache.test.questions.map(x=>x.id));
document.getElementById(`sc-pick-${tid}`).innerHTML = renderScPicker(filtered, inIds, tid, slug);
}
async function scAddQ(tid, slug, qid, btn) {
btn.disabled = true; btn.textContent = '…';
try {
await LS.addQuestionsToTest(tid, [qid]);
const t = await LS.getTest(tid);
_scCache[tid].test = t;
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
if (item) { item.style.opacity='0.4'; item.style.pointerEvents='none'; }
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
if (addBtn) { addBtn.innerHTML = '<i data-lucide="check" style="width:14px;height:14px"></i>'; if(window.lucide)lucide.createIcons(); }
AdminCtx.renderMath(document.getElementById(`sc-ql-${tid}`));
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled=false; btn.textContent='+'; }
}
async function scRemoveQ(tid, slug, qid) {
try {
await LS.removeQFromTest(tid, qid);
const t = await LS.getTest(tid);
_scCache[tid].test = t;
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
if (item) { item.style.opacity=''; item.style.pointerEvents=''; }
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
if (addBtn) { addBtn.textContent='+'; addBtn.disabled=false; }
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose onclick handlers
window.toggleScCard = toggleScCard;
window.applyPreset = applyPreset;
window.setSrcMode = setSrcMode;
window.saveSubjectConfig = saveSubjectConfig;
window.onScTestChange = onScTestChange;
window.toggleScDrawer = toggleScDrawer;
window.filterScPicker = filterScPicker;
window.scAddQ = scAddQ;
window.scRemoveQ = scRemoveQ;
window.AdminSections = window.AdminSections || {};
window.AdminSections.subjects = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+104
View File
@@ -0,0 +1,104 @@
'use strict';
/* admin → sublog (submission log) section */
(function () {
'use strict';
let inited = false;
const SL_STATUSES = { new:'На проверке', reviewed:'Проверено', accepted:'Принято', revision:'На доработке', resubmitted:'Повторно' };
async function load() {
const el = document.getElementById('sublog-list');
const countEl = document.getElementById('sublog-count');
const classId = document.getElementById('sublog-class-filter').value;
el.innerHTML = '<div class="spinner"></div>';
countEl.textContent = '';
try {
const url = classId ? `/api/submissions/log?class_id=${classId}` : '/api/submissions/log';
const rows = await LS.api(url);
// Populate class filter on first load
const sel = document.getElementById('sublog-class-filter');
if (sel.options.length <= 1 && rows.length) {
const classMap = new Map();
rows.forEach(r => { if (r.class_id && r.class_name) classMap.set(r.class_id, r.class_name); });
classMap.forEach((name, id) => {
const opt = document.createElement('option');
opt.value = id; opt.textContent = name;
sel.appendChild(opt);
});
}
countEl.textContent = rows.length ? `${rows.length} записей` : '';
if (!rows.length) {
el.innerHTML = `<div class="sl-empty">
<div class="sl-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
Удалённых работ нет
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [el] });
return;
}
const ROLE_LABELS = { admin: 'Админ', teacher: 'Учитель', student: 'Ученик' };
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
<thead><tr>
<th>Дата</th>
<th>Ученик</th>
<th>Файл</th>
<th>Задание</th>
<th>Класс</th>
<th>Статус</th>
<th>Оценка</th>
<th>Удалил</th>
</tr></thead>
<tbody>${rows.map(r => {
const dt = r.deleted_at ? new Date(r.deleted_at.includes('T') ? r.deleted_at : r.deleted_at.replace(' ','T')+'Z') : null;
const dateStr = dt ? dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}) : '—';
const initials = (r.student_name || '?').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
const st = r.status || 'new';
const gradeVal = r.grade != null ? r.grade : null;
const gradeCls = gradeVal != null ? (gradeVal >= 80 ? 'sl-grade-hi' : gradeVal >= 50 ? 'sl-grade-mid' : 'sl-grade-lo') : 'sl-grade-none';
const roleCls = 'sl-role-' + (r.deleted_by_role || 'student');
return `<tr>
<td><span class="sl-date">${dateStr}</span></td>
<td><span class="sl-student"><span class="sl-student-avatar">${initials}</span>${esc(r.student_name || '')}</span></td>
<td><span class="sl-file" title="${esc(r.original_name || '')}">${esc(r.original_name || '—')}</span></td>
<td><span class="sl-assignment">${esc(r.assignment_title || '—')}</span></td>
<td><span class="sl-class">${esc(r.class_name || '—')}</span></td>
<td><span class="sl-status sl-status-${st}">${SL_STATUSES[st] || st}</span></td>
<td><span class="sl-grade ${gradeCls}">${gradeVal != null ? gradeVal : '—'}</span></td>
<td><span class="sl-deleted-by">${esc(r.deleted_by_name || '—')} <span class="sl-role-badge ${roleCls}">${ROLE_LABELS[r.deleted_by_role] || r.deleted_by_role || '?'}</span></span></td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
document.getElementById('btn-clear-sublog').style.display = '';
} catch (e) {
el.innerHTML = `<div class="sl-empty" style="color:#c0306a">Ошибка: ${esc(e.message)}</div>`;
}
}
async function clearSubmissionLog() {
if (!await LS.confirm('Очистить весь журнал удалённых работ? Это действие необратимо.', { title: 'Очистка журнала', confirmText: 'Очистить', danger: true })) return;
try {
await LS.api('/api/submissions/log', { method: 'DELETE' });
document.getElementById('btn-clear-sublog').style.display = 'none';
document.getElementById('sublog-count').textContent = '';
document.getElementById('sublog-list').innerHTML = `<div class="sl-empty">
<div class="sl-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
Журнал очищен
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [document.getElementById('sublog-list')] });
LS.toast('Журнал очищен', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers used by HTML onclicks
window.loadSubmissionLog = load;
window.clearSubmissionLog = clearSubmissionLog;
window.AdminSections = window.AdminSections || {};
window.AdminSections.sublog = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+283
View File
@@ -0,0 +1,283 @@
'use strict';
/* admin → tests section (тест-шаблоны: создание + редактирование + список вопросов) */
(function () {
'use strict';
let inited = false;
let allTests = [];
let openTstId = null;
let editingTstId = null;
let _tstShowAnswers = true;
const _tstPickerCache = {};
async function load() {
const subj = document.getElementById('tst-subj').value;
const wrap = document.getElementById('tst-list-wrap');
wrap.innerHTML = '<div class="spinner"></div>';
try {
allTests = await LS.getTests(subj || null);
renderTests();
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderTests() {
const { fmtDate } = AdminCtx;
const search = document.getElementById('tst-search').value.toLowerCase();
const filtered = search ? allTests.filter(t => t.title.toLowerCase().includes(search)) : allTests;
document.getElementById('tst-count').textContent = `${filtered.length} тестов`;
const wrap = document.getElementById('tst-list-wrap');
if (!filtered.length) { wrap.innerHTML = '<div class="empty">Тестов не найдено</div>'; return; }
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
wrap.innerHTML = `<div class="q-list">${filtered.map(t => `
<div class="q-card" id="tstcard-${t.id}">
<div class="q-card-head">
<span class="q-card-num">#${t.id}</span>
<div class="q-card-body" onclick="toggleTstDrawer(${t.id})">
<div class="q-card-text">${esc(t.title)}</div>
<div class="q-card-meta">
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
</div>
</div>
<div class="q-card-actions">
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>
<div class="tst-drawer" id="tstdrawer-${t.id}" style="display:none">
<div class="tst-drawer-inner" id="tstdinner-${t.id}">
<div class="spinner"></div>
</div>
</div>
</div>`).join('')}</div>`;
if (window.lucide) lucide.createIcons();
}
async function toggleTstDrawer(id) {
const drawer = document.getElementById('tstdrawer-' + id);
if (!drawer) return;
if (openTstId && openTstId !== id) {
const old = document.getElementById('tstdrawer-' + openTstId);
if (old) old.style.display = 'none';
}
if (openTstId === id) {
drawer.style.display = 'none'; openTstId = null; return;
}
openTstId = id;
drawer.style.display = '';
await renderTstDrawer(id);
}
async function renderTstDrawer(id) {
const inner = document.getElementById('tstdinner-' + id);
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const [t, subjectQs] = await Promise.all([
LS.getTest(id),
LS.getQuestions(
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
null, 'date_asc'
).catch(() => []),
]);
const inIds = new Set(t.questions.map(q => q.id));
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
inner.innerHTML = `
<div class="tst-cols">
<div>
<div class="tst-panel-title">Вопросы в тесте (${t.questions.length})</div>
<div class="tst-q-list" id="tstql-${id}">${renderTstQList(t.questions, id)}</div>
</div>
<div>
<div class="tst-panel-title">Добавить вопросы</div>
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
</div>
</div>`;
AdminCtx.renderMath(inner);
if (window.lucide) lucide.createIcons();
} catch (e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderTstQList(questions, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></div>';
return questions.map((q, i) => `
<div class="tst-q-item" id="tstqitem-${tid}-${q.id}">
<span class="tst-q-num">${i+1}.</span>
<div class="tst-q-body">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
${qTypeBadge(q.type)}
${qOptsPreview(q)}
</div>
</div>
<button class="btn-tst-rem" onclick="tstRemoveQ(${tid},${q.id})" title="Убрать"></button>
</div>`).join('');
}
function renderTstPicker(questions, inIds, tid) {
const { DIFF_LABELS, qTypeBadge, qOptsPreview } = AdminCtx;
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
return questions.map(q => {
const added = inIds.has(q.id);
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
<div class="tst-q-body">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||''}</span>
${qTypeBadge(q.type)}
${qOptsPreview(q)}
</div>
</div>
<button class="btn-tst-add${added?' added':''}" id="tstbtn-${tid}-${q.id}"
title="${added?'Уже в тесте':'Добавить'}" ${added?'disabled':'onclick="tstAddQ('+tid+','+q.id+')"'}>${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+'}</button>
</div>`;
}).join('');
}
async function filterTstPicker(tid) {
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
const cache = _tstPickerCache[tid];
if (!cache) return;
const filtered = search
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
: cache.subjectQs;
const picker = document.getElementById('tstpicker-'+tid);
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); AdminCtx.renderMath(picker); if(window.lucide)lucide.createIcons(); }
}
async function tstAddQ(tid, qid) {
const btn = document.getElementById(`tstbtn-${tid}-${qid}`);
if (btn) { btn.disabled = true; btn.textContent = '…'; }
try {
await LS.addQuestionsToTest(tid, [qid]);
const t = allTests.find(x => x.id === tid);
if (t) t.question_count++;
renderTests();
openTstId = tid;
document.getElementById('tstdrawer-' + tid).style.display = '';
await renderTstDrawer(tid);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); if (btn) { btn.disabled=false; btn.textContent='+'; } }
}
async function tstRemoveQ(tid, qid) {
try {
await LS.removeQFromTest(tid, qid);
const t = allTests.find(x => x.id === tid);
if (t) t.question_count = Math.max(0, t.question_count - 1);
renderTests();
openTstId = tid;
document.getElementById('tstdrawer-' + tid).style.display = '';
await renderTstDrawer(tid);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ── Test modal ── */
function setTstShowAnswers(val) {
_tstShowAnswers = val;
document.getElementById('tstf-show-yes').classList.toggle('active', val);
document.getElementById('tstf-show-no').classList.toggle('active', !val);
}
function openTstModal(t = null) {
editingTstId = t ? t.id : null;
document.getElementById('tst-modal-title').textContent = t ? `Редактировать: ${t.title}` : 'Создать тест';
document.getElementById('tstf-title').value = t?.title || '';
document.getElementById('tstf-subject').value = t?.subject_slug || '';
document.getElementById('tstf-desc').value = t?.description || '';
document.getElementById('tstf-time').value = t?.time_limit || '';
document.getElementById('tstf-error').textContent = '';
setTstShowAnswers(t ? (t.show_answers !== 0) : true);
document.getElementById('tst-modal').classList.add('open');
setTimeout(() => document.getElementById('tstf-title').focus(), 80);
}
function editTst(id) {
const t = allTests.find(x => x.id === id);
if (t) openTstModal(t);
}
function closeTstModal() {
document.getElementById('tst-modal').classList.remove('open');
editingTstId = null;
}
async function saveTst() {
const title = document.getElementById('tstf-title').value.trim();
const subject_slug= document.getElementById('tstf-subject').value;
const description = document.getElementById('tstf-desc').value.trim();
const errEl = document.getElementById('tstf-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
const btn = document.getElementById('tstf-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
const show_answers = _tstShowAnswers ? 1 : 0;
const timeVal = parseInt(document.getElementById('tstf-time').value, 10);
const time_limit = timeVal >= 1 ? Math.min(600, timeVal) : null;
try {
if (editingTstId) {
await LS.updateTest(editingTstId, { title, subject_slug, description: description||null, show_answers, time_limit });
const idx = allTests.findIndex(x => x.id === editingTstId);
if (idx !== -1) Object.assign(allTests[idx], { title, subject_slug, description, show_answers, time_limit });
} else {
const { id } = await LS.createTest({ title, subject_slug, description: description||null, show_answers, time_limit });
allTests.unshift({ id, title, subject_slug, description, question_count: 0, created_at: new Date().toISOString() });
closeTstModal();
renderTests();
openTstId = id;
document.getElementById('tstdrawer-' + id).style.display = '';
await renderTstDrawer(id);
return;
}
closeTstModal();
renderTests();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
async function deleteTst(id) {
const t = allTests.find(x => x.id === id);
if (!await LS.confirm(`Удалить тест «${t?.title}»?`, { title: 'Удалить тест', confirmText: 'Удалить' })) return;
try {
await LS.deleteTest(id);
allTests = allTests.filter(x => x.id !== id);
if (openTstId === id) openTstId = null;
renderTests();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers
window.loadTests = load;
window.renderTests = renderTests;
window.toggleTstDrawer = toggleTstDrawer;
window.filterTstPicker = filterTstPicker;
window.tstAddQ = tstAddQ;
window.tstRemoveQ = tstRemoveQ;
window.setTstShowAnswers = setTstShowAnswers;
window.openTstModal = openTstModal;
window.editTst = editTst;
window.closeTstModal = closeTstModal;
window.saveTst = saveTst;
window.deleteTst = deleteTst;
window.AdminSections = window.AdminSections || {};
window.AdminSections.tests = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+73
View File
@@ -0,0 +1,73 @@
'use strict';
/* admin → tpl (templates: courses + lessons) section */
(function () {
'use strict';
let inited = false;
async function load() {
try {
const [courses, lessons] = await Promise.all([
LS.getCourseTemplates().catch(() => []),
LS.getLessonTemplates().catch(() => [])
]);
renderTplTable('tpl-course-body', courses, 'courses');
renderTplTable('tpl-lesson-body', lessons, 'lessons');
if (window.lucide) lucide.createIcons();
} catch(e) {
document.getElementById('tpl-course-body').innerHTML = `<tr><td colspan="7" class="error">Ошибка: ${esc(e.message)}</td></tr>`;
}
}
function renderTplTable(bodyId, items, type) {
const body = document.getElementById(bodyId);
if (!items || !items.length) {
body.innerHTML = '<tr><td colspan="7" class="empty">Нет шаблонов</td></tr>';
return;
}
body.innerHTML = items.map(t => `<tr>
<td>${t.id}</td>
<td><strong>${esc(t.name || t.title || '—')}</strong></td>
<td>${esc(t.subject || '—')}</td>
<td>${esc(t.category || '—')}</td>
<td>${esc(t.author_name || t.author || '—')}</td>
<td>
<label class="adm-toggle">
<input type="checkbox" ${t.is_public ? 'checked' : ''} onchange="tplTogglePublic('${type}', ${t.id}, this.checked)" />
<span class="track"></span><span class="thumb"></span>
</label>
</td>
<td>
<button class="btn-del-q" onclick="tplDelete('${type}', ${t.id})">Удалить</button>
</td>
</tr>`).join('');
}
async function tplTogglePublic(type, id, isPublic) {
try {
const endpoint = type === 'courses' ? '/api/templates/courses/' : '/api/templates/lessons/';
await LS.api(endpoint + id, { method: 'PUT', body: JSON.stringify({ is_public: isPublic ? 1 : 0 }) });
LS.toast(isPublic ? 'Шаблон опубликован' : 'Шаблон скрыт', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function tplDelete(type, id) {
if (!confirm('Удалить шаблон #' + id + '?')) return;
try {
if (type === 'courses') await LS.deleteCourseTemplate(id);
else await LS.deleteLessonTemplate(id);
LS.toast('Шаблон удалён', 'success');
inited = false;
await load();
inited = true;
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
window.tplTogglePublic = tplTogglePublic;
window.tplDelete = tplDelete;
window.AdminSections = window.AdminSections || {};
window.AdminSections.tpl = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();
+343
View File
@@ -0,0 +1,343 @@
'use strict';
/* admin → users section: users table + pagination + user-panel overlay + user-perms modal */
(function () {
'use strict';
let inited = false;
let _usersPage = 1;
const _USERS_PER_PAGE = 50;
// user-panel + edit modal + perms modal state
let activeTr = null;
let activeUid = null;
let activeUserRole = null;
let _editUid = null;
let _upPermsData = null;
async function load(page) {
const { pctClass, fmtDate, renderPgnControls } = AdminCtx;
const isAdmin = AdminCtx.isAdmin;
const user = AdminCtx.user;
if (page) _usersPage = page;
try {
const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE });
const users = r.users || [];
const tbody = document.getElementById('users-body');
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Пользователей нет</div></td></tr>';
document.getElementById('users-pagination').style.display = 'none';
return;
}
tbody.innerHTML = users.map(u => {
const pc = pctClass(u.avg_pct);
const initials = (u.name||'?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'?';
const avatarBg = u.role==='admin' ? 'linear-gradient(135deg,#9B5DE5,#c084fc)' : u.role==='teacher' ? 'linear-gradient(135deg,#06D6E0,#9B5DE5)' : u.role==='free_student' ? 'linear-gradient(135deg,#10B981,#059669)' : 'linear-gradient(135deg,#8898AA,#3D4F6B)';
const roleCell = isAdmin && u.id !== user.id
? `<select class="role-select" data-uid="${u.id}" onchange="changeRole(this)">
<option value="student" ${u.role==='student' ?'selected':''}>Ученик</option>
<option value="free_student" ${u.role==='free_student' ?'selected':''}>Своб. ученик</option>
<option value="teacher" ${u.role==='teacher' ?'selected':''}>Учитель</option>
<option value="admin" ${u.role==='admin' ?'selected':''}>Админ</option>
</select>`
: `<span class="role-badge ${u.role}">${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}</span>`;
return `<tr class="clickable${u.is_banned ? ' banned-row' : ''}" onclick="openUserPanel(event,${u.id},'${u.role}')">
<td>
<div style="display:flex;align-items:center;gap:12px">
<div style="width:36px;height:36px;border-radius:10px;background:${avatarBg};display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.62rem;font-weight:800;color:#fff;flex-shrink:0;${u.is_banned?'filter:grayscale(1);opacity:.5':''}">${initials}</div>
<div>
<div style="font-weight:700;font-size:0.88rem;color:var(--text)">${esc(u.name)}${u.is_banned ? ' <span style="font-size:0.7rem;background:rgba(239,68,68,.12);color:#EF4444;border-radius:4px;padding:1px 5px;font-weight:600;vertical-align:middle">заблокирован</span>' : ''}</div>
<div style="color:var(--text-3);font-size:0.76rem">${esc(u.email)}</div>
</div>
</div>
</td>
<td onclick="event.stopPropagation()">${roleCell}</td>
<td style="font-weight:700">${u.tests_count}</td>
<td>
<span class="pct-cell ${pc}">${u.avg_pct !== null ? u.avg_pct+'%' : '—'}</span>
${u.avg_pct !== null ? `<div class="perf-bar"><div class="perf-fill ${pc}" style="width:${u.avg_pct}%"></div></div>` : ''}
</td>
<td style="color:var(--text-3);font-size:0.8rem">${fmtDate(u.created_at)}</td>
<td style="color:var(--text-3);font-size:0.8rem">${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'}</td>
<td style="text-align:right;color:var(--text-3);font-size:0.85rem;opacity:0.4"></td>
</tr>`;
}).join('');
renderPgnControls('users-pagination', _usersPage, r.total || users.length, _USERS_PER_PAGE, 'gotoUsersPage');
} catch (e) {
document.getElementById('users-body').innerHTML = `<tr><td colspan="7"></td></tr>`;
LS.state.error(document.getElementById('users-body').querySelector('td'), e, load);
}
}
function gotoUsersPage(n) {
_usersPage = n;
load();
document.getElementById('tab-users')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function changeRole(select) {
select.disabled = true;
try { await LS.adminUpdateRole(select.dataset.uid, select.value); LS.toast('Роль изменена', 'success', 2000); }
catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { select.disabled = false; }
}
/* ─── User panel ─── */
async function openUserPanel(e, uid, role) {
const isAdmin = AdminCtx.isAdmin;
if (activeTr) activeTr.classList.remove('selected');
activeTr = e.currentTarget; activeTr.classList.add('selected');
activeUid = uid;
activeUserRole = role;
const panel = document.getElementById('user-panel');
panel.classList.add('visible');
panel.scrollIntoView({ behavior:'smooth', block:'nearest' });
document.getElementById('up-sessions').innerHTML = LS.skeleton(3, 'row');
document.getElementById('up-name').textContent = '…';
document.getElementById('up-email').textContent = '';
if (isAdmin) {
document.getElementById('up-edit-btn').style.display = '';
document.getElementById('up-clear-btn').style.display = '';
document.getElementById('up-perms-btn').style.display = role === 'teacher' ? '' : 'none';
document.getElementById('up-ban-btn').style.display = '';
document.getElementById('up-delete-btn').style.display = '';
}
await reloadUserPanel(uid);
}
async function reloadUserPanel(uid) {
const { MODES, pctClass, fmtDate } = AdminCtx;
const isAdmin = AdminCtx.isAdmin;
try {
const { user: u, sessions } = await LS.adminGetUserSessions(uid);
activeUserRole = u.role;
document.getElementById('up-name').innerHTML = LS.esc(u.name) + (u.is_banned ? ' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' : '');
document.getElementById('up-email').textContent = u.email;
if (isAdmin) {
document.getElementById('up-perms-btn').style.display = u.role === 'teacher' ? '' : 'none';
const banBtn = document.getElementById('up-ban-btn');
const banLbl = document.getElementById('up-ban-label');
if (u.is_banned) {
banBtn.style.background = 'rgba(34,197,94,.12)';
banBtn.style.color = '#22C55E';
banBtn.style.borderColor = 'rgba(34,197,94,.25)';
banLbl.textContent = 'Разблокировать';
} else {
banBtn.style.background = '';
banBtn.style.color = '';
banBtn.style.borderColor = '';
banLbl.textContent = 'Заблокировать';
}
}
const el = document.getElementById('up-sessions');
if (!sessions.length) { el.innerHTML = '<div class="empty">Тестов нет</div>'; return; }
el.innerHTML = '<div class="sess-list">' + sessions.map(s => {
const pct = s.score !== null ? Math.round((s.score/s.total)*100) : null;
return `<div class="sess-item">
<div class="sess-pct ${pctClass(pct)}">${pct !== null ? pct+'%' : '—'}</div>
<div class="sess-info"><div class="sess-subj">${s.subject_name||'Тест'}</div><div class="sess-meta">${fmtDate(s.started_at)} · ${MODES[s.mode]||s.mode}</div></div>
<div class="sess-score">${s.score??'—'} / ${s.total}</div>
</div>`;
}).join('') + '</div>';
} catch (e) { LS.state.error(document.getElementById('up-sessions'), e); }
}
function closeUserPanel() {
document.getElementById('user-panel').classList.remove('visible');
if (activeTr) { activeTr.classList.remove('selected'); activeTr = null; }
activeUid = null;
}
async function clearUserHistory() {
const name = document.getElementById('up-name').textContent;
if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return;
try {
await LS.adminClearUserSessions(activeUid);
await reloadUserPanel(activeUid);
load();
} catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); }
}
async function toggleBanUser() {
const banLbl = document.getElementById('up-ban-label');
const isBanning = banLbl.textContent === 'Заблокировать';
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
const msg = isBanning
? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.`
: `Разблокировать пользователя «${name}»?`;
if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return;
try {
await LS.adminBanUser(activeUid, isBanning);
LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success');
await reloadUserPanel(activeUid);
load();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function confirmDeleteUser() {
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return;
try {
await LS.adminDeleteUser(activeUid);
LS.toast('Пользователь удалён', 'success');
closeUserPanel();
load();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── Edit user modal ─── */
function closeEditUserModal() {
document.getElementById('eu-modal').classList.remove('open');
_editUid = null;
}
function openEditUserModal() {
_editUid = activeUid;
document.getElementById('eu-name').value = document.getElementById('up-name').textContent;
document.getElementById('eu-email').value = document.getElementById('up-email').textContent;
document.getElementById('eu-password').value = '';
document.getElementById('eu-error').textContent = '';
document.getElementById('eu-modal').classList.add('open');
setTimeout(() => document.getElementById('eu-name').focus(), 80);
}
async function saveEditUser() {
const name = document.getElementById('eu-name').value.trim();
const email = document.getElementById('eu-email').value.trim();
const password = document.getElementById('eu-password').value;
const errEl = document.getElementById('eu-error');
errEl.textContent = '';
if (!name) { errEl.textContent = 'Введите имя'; return; }
if (!email) { errEl.textContent = 'Введите email'; return; }
if (password && password.length < 6) { errEl.textContent = 'Пароль должен быть не менее 6 символов'; return; }
const payload = { name, email };
if (password) payload.password = password;
const btn = document.getElementById('eu-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
await LS.adminUpdateUser(_editUid, payload);
closeEditUserModal();
await reloadUserPanel(activeUid);
load();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ─── User permissions modal (opened from inside user-panel) ─── */
function closeUserPermsModal() {
document.getElementById('up-modal').classList.remove('open');
_upPermsData = null;
}
async function openUserPermsModal() {
if (!activeUid) return;
const name = document.getElementById('up-name').textContent;
document.getElementById('up-modal-title').textContent = `Права: ${name}`;
document.getElementById('up-modal-list').innerHTML = LS.skeleton(5, 'row');
document.getElementById('up-modal').classList.add('open');
try {
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
} catch(e) {
document.getElementById('up-modal-list').innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
}
}
function renderUserPerms() {
if (!_upPermsData) return;
const list = document.getElementById('up-modal-list');
list.innerHTML = _upPermsData.permissions.map(p => {
const hasOverride = p.userVal !== undefined;
const checked = p.effective;
const badge = hasOverride
? `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(155,93,229,0.12);color:var(--violet);font-weight:700">Инд.</span>`
: `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(136,152,170,0.12);color:var(--text-3);font-weight:700">По роли</span>`;
const resetBtn = hasOverride
? `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:700;transition:color .2s"
onmouseover="this.style.color='var(--danger)'" onmouseout="this.style.color='var(--text-3)'"
onclick="doResetOneUserPerm('${esc(p.key)}')" title="Сбросить к роли">×</button>`
: '';
return `
<div class="perm-card${checked ? ' enabled' : ''}" id="up-perm-card-${p.key.replace('.','_')}">
<div class="perm-info">
<div style="display:flex;align-items:center;gap:7px">
<span class="perm-label">${esc(p.label)}</span>
${badge}
${resetBtn}
</div>
<div class="perm-desc">${esc(p.desc)}</div>
</div>
<label class="perm-toggle">
<input type="checkbox" ${checked ? 'checked' : ''}
onchange="doSetUserPerm('${esc(p.key)}', this.checked, this)">
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>`;
}).join('');
const hasAny = _upPermsData.permissions.some(p => p.userVal !== undefined);
document.getElementById('up-modal-reset-btn').style.opacity = hasAny ? '1' : '0.4';
}
async function doSetUserPerm(key, enabled, checkbox) {
checkbox.disabled = true;
try {
await LS.setUserPermission(activeUid, key, enabled);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
} finally {
checkbox.disabled = false;
}
}
async function doResetOneUserPerm(key) {
try {
await LS.resetUserPermissions(activeUid, key);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast('Сброшено к значению роли', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function doResetAllUserPerms() {
const name = document.getElementById('up-name').textContent;
if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return;
try {
await LS.resetUserPermissions(activeUid);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast('Права сброшены к роли', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers used by HTML onclicks
window.loadUsers = load;
window.gotoUsersPage = gotoUsersPage;
window.changeRole = changeRole;
window.openUserPanel = openUserPanel;
window.reloadUserPanel = reloadUserPanel;
window.closeUserPanel = closeUserPanel;
window.clearUserHistory = clearUserHistory;
window.toggleBanUser = toggleBanUser;
window.confirmDeleteUser = confirmDeleteUser;
window.closeEditUserModal = closeEditUserModal;
window.openEditUserModal = openEditUserModal;
window.saveEditUser = saveEditUser;
window.closeUserPermsModal = closeUserPermsModal;
window.openUserPermsModal = openUserPermsModal;
window.doSetUserPerm = doSetUserPerm;
window.doResetOneUserPerm = doResetOneUserPerm;
window.doResetAllUserPerms = doResetAllUserPerms;
window.AdminSections = window.AdminSections || {};
window.AdminSections.users = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();