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
+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,
};
})();