'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 = ''; 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 = `
Ошибка загрузки: ${esc(e.message)}
`; } } 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 = '
Вопросов не найдено
'; return; } const wrap = document.getElementById('q-list-wrap'); wrap.innerHTML = `
${filtered.map(q => { const diffCls = `diff-${q.difficulty}`; const optsHtml = (q.options || []).map(o => `
${o.is_correct ? '' : ''}${q.allow_html ? o.text : esc(o.text)}
`).join(''); const explHtml = q.explanation ? `
Пояснение: ${q.allow_html ? q.explanation : esc(q.explanation)}
` : ''; return `
#${q.id}
${q.allow_html ? q.text : esc(q.text)}
${q.subject_name ? `${esc(q.subject_name)}` : ''} ${q.topic ? `${esc(q.topic)}` : ''} ${DIFFS[q.difficulty]||q.difficulty} ${{single:'Один',multi:'Несколько',true_false:'Верно/Неверно',short_answer:'Краткий',matching:'Сопост.'}[q.type]||q.type||'Один'} ${q.options?.length||0} вар.
${optsHtml}${explHtml}
`; }).join('')}
`; 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) => `
`).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) => `
${OPT_LETTERS[i]} ${isMulti ? `` : ``} ${opts.length > 2 ? `` : ''}
`).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 = ' Импорт 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.updateCharCounter = updateCharCounter; window.AdminSections = window.AdminSections || {}; window.AdminSections.questions = { init: async () => { if (inited) return; inited = true; await load(); }, reload: load, openModal: openQModal, loadModalTopics: loadQModalTopics, }; })();