Files
Maxim Dolgolyov 92030b462c 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.
2026-05-16 22:50:14 +03:00

284 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};
})();