bc22715734
Новый общий компонент LS.modal (api.js) — companion к LS.confirm.
Универсальная form/content-модалка с консистентным поведением:
LS.modal({
title, content, size: 'sm'|'md'|'lg',
actions: [{label, primary, danger, onClick}],
onClose,
});
// Returns { close, root, body, setBody, setActions, setError }
Стандартное поведение:
- ESC и backdrop-click закрывают (опциональный dismissible:false)
- z-index 9000 (тот же что LS.confirm — без конфликтов)
- Auto-focus первого input/select/textarea/button в body
- prevFocus restore при закрытии
- Анимация scale+translateY .22s
- Адаптив: на мобилках padding уменьшается
CSS-классы .ls-mov / .ls-mod / .ls-mod-hdr / .ls-mod-body / .ls-mod-act
впрыскиваются один раз из api.js (id=ls-modal-style), как и стили
toast/confirm.
Миграция exam9 «Назначить вариант»:
- Убран inline <div class="ex-overlay" id="assign-overlay">…</div>
- Убраны .ax-actions, .ax-btn, .ax-btn-primary, .ax-error, .ax-success
CSS (теперь в общих .ls-mod-* стилях)
- openAssignModal → LS.modal({ title, content: form, actions: [...] })
- Удалены closeAssignModal/onAssignOverlayClick/onAssignEsc — теперь
handle'ит LS.modal
- Удалена unused переменная assignVariantNum (closure теперь над varNum)
exam9.html: −53 строк (CSS + HTML модалки)
app.js: переписан 90 строк → 70 строк
Миграция my-students «Убрать ученика»:
- native confirm() → LS.confirm() с danger-стилизацией
- alert() → LS.toast() для согласованности
Сохранён классroom-овский «ex-overlay»/«ex-panel» CSS (используется
для picker'а вариантов в exam9). Не трогаем classroom.html — у него
своя ecosystem cr-*-overlay.
Дальше — postupенная миграция модалок в textbooks/classes/admin
по мере касания этих страниц. Шаблон установлен.
290 lines
11 KiB
JavaScript
290 lines
11 KiB
JavaScript
'use strict';
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
Exam 9 — Math 2025 renderer
|
||
Variants loaded into window.VARIANTS by /js/exam9/variants/vNN.js
|
||
────────────────────────────────────────────────────────────────── */
|
||
|
||
const STORAGE_KEY = 'exam9_progress_v1';
|
||
let currentVariant = null;
|
||
let katexLoaded = false;
|
||
let variantTests = {}; // { variantNum: testId } — populated by /api/exam9/variants
|
||
let userRole = null; // populated by LS.getUser()
|
||
let teacherClasses = null; // lazy-loaded from /api/classes
|
||
|
||
/* ── KaTeX bootstrap ────────────────────────────────────────────── */
|
||
function onKatexLoad() {
|
||
katexLoaded = true;
|
||
if (currentVariant !== null) runKatex(document.getElementById('ex-main'));
|
||
}
|
||
|
||
function runKatex(el) {
|
||
if (!katexLoaded || !el) return;
|
||
try {
|
||
renderMathInElement(el, {
|
||
delimiters: [
|
||
{ left: '$$', right: '$$', display: true },
|
||
{ left: '$', right: '$', display: false },
|
||
],
|
||
throwOnError: false,
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
/* ── Progress in localStorage ───────────────────────────────────── */
|
||
function loadProgress() {
|
||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); }
|
||
catch { return {}; }
|
||
}
|
||
function saveProgress(p) {
|
||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); } catch {}
|
||
}
|
||
function markSolutionViewed(variantNum, taskIdx) {
|
||
const p = loadProgress();
|
||
p[variantNum] = p[variantNum] || [];
|
||
if (!p[variantNum].includes(taskIdx)) {
|
||
p[variantNum].push(taskIdx);
|
||
saveProgress(p);
|
||
}
|
||
}
|
||
|
||
/* ── Variant picker ─────────────────────────────────────────────── */
|
||
function buildGrid() {
|
||
const grid = document.getElementById('variant-grid');
|
||
const progress = loadProgress();
|
||
grid.innerHTML = '';
|
||
Object.keys(VARIANTS).sort((a, b) => Number(a) - Number(b)).forEach(n => {
|
||
const v = VARIANTS[n];
|
||
const total = (v.tasks || []).length;
|
||
const viewed = (progress[n] || []).length;
|
||
let cls = '';
|
||
if (viewed === total && total > 0) cls = ' done';
|
||
else if (viewed > 0) cls = ' partial';
|
||
const isActive = Number(n) === currentVariant ? ' active' : '';
|
||
|
||
const btn = document.createElement('button');
|
||
btn.className = 'vg-btn' + cls + isActive;
|
||
btn.textContent = n;
|
||
btn.title = `${v.label}${viewed === total ? ' ✓' : viewed > 0 ? ` (${viewed}/${total})` : ''}`;
|
||
btn.onclick = () => { selectVariant(Number(n)); closePicker(); };
|
||
grid.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function togglePicker() {
|
||
const overlay = document.getElementById('picker-overlay');
|
||
const btn = document.getElementById('picker-btn');
|
||
if (overlay.classList.contains('visible')) closePicker();
|
||
else {
|
||
buildGrid();
|
||
overlay.classList.add('visible');
|
||
btn.classList.add('open');
|
||
document.addEventListener('keydown', onEsc);
|
||
}
|
||
}
|
||
|
||
function closePicker() {
|
||
document.getElementById('picker-overlay').classList.remove('visible');
|
||
document.getElementById('picker-btn').classList.remove('open');
|
||
document.removeEventListener('keydown', onEsc);
|
||
}
|
||
|
||
function onOverlayClick(e) {
|
||
if (e.target === document.getElementById('picker-overlay')) closePicker();
|
||
}
|
||
function onEsc(e) { if (e.key === 'Escape') closePicker(); }
|
||
|
||
/* ── Task rendering ─────────────────────────────────────────────── */
|
||
function buildOpts(opts) {
|
||
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
|
||
const cls = isLong ? 'opts-vertical' : 'opts';
|
||
return `<div class="${cls}">` +
|
||
opts.map(([l, t]) =>
|
||
`<span class="opt"><span class="opt-lbl">${l})</span><span>${t}</span></span>`
|
||
).join('') + `</div>`;
|
||
}
|
||
|
||
const SOL_ICON_CLOSED = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
|
||
|
||
function renderVariant(num) {
|
||
const main = document.getElementById('ex-main');
|
||
const v = VARIANTS[num];
|
||
if (!v) {
|
||
main.innerHTML = '<div class="ex-empty">Вариант не найден</div>';
|
||
return;
|
||
}
|
||
|
||
const isTeacher = userRole === 'teacher' || userRole === 'admin';
|
||
const testId = variantTests[num];
|
||
const assignBtn = isTeacher
|
||
? `<div class="ex-assign-row">
|
||
<button class="ex-assign-btn" ${testId ? `onclick="openAssignModal(${num})"` : 'disabled'}>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
|
||
Назначить как ДЗ
|
||
</button>
|
||
${testId ? '' : '<span class="ex-assign-note">Этот вариант ещё не импортирован в банк (только нечётные)</span>'}
|
||
</div>`
|
||
: '';
|
||
|
||
main.innerHTML =
|
||
`<div class="variant-title">${v.label}<small>${v.tasks.length} заданий</small></div>` +
|
||
assignBtn +
|
||
v.tasks.map((t, i) => `
|
||
<div class="task-card">
|
||
<div class="task-header">
|
||
<div class="task-num">${i + 1}</div>
|
||
<div class="task-label">Задание ${i + 1}</div>
|
||
</div>
|
||
<div class="task-body">
|
||
<div class="task-text">${t.text}</div>
|
||
${t.figure ? `<div class="task-figure">${t.figure}</div>` : ''}
|
||
${t.opts ? buildOpts(t.opts) : ''}
|
||
</div>
|
||
${t.sol ? `<div class="sol-wrap">
|
||
<button class="sol-btn" data-task="${i}" onclick="toggleSol(this, ${num}, ${i})">
|
||
${SOL_ICON_CLOSED}<span>Показать решение</span>
|
||
</button>
|
||
<div class="sol-panel">${t.sol}</div>
|
||
</div>` : ''}
|
||
</div>`
|
||
).join('');
|
||
|
||
runKatex(main);
|
||
}
|
||
|
||
function toggleSol(btn, variantNum, taskIdx) {
|
||
const panel = btn.nextElementSibling;
|
||
const open = panel.classList.contains('visible');
|
||
panel.classList.toggle('visible', !open);
|
||
btn.classList.toggle('open', !open);
|
||
btn.querySelector('span').textContent = open ? 'Показать решение' : 'Скрыть решение';
|
||
if (!open) {
|
||
if (!panel.dataset.k) { runKatex(panel); panel.dataset.k = '1'; }
|
||
markSolutionViewed(variantNum, taskIdx);
|
||
}
|
||
}
|
||
|
||
function selectVariant(num) {
|
||
currentVariant = num;
|
||
document.getElementById('picker-label').textContent = VARIANTS[num].label;
|
||
document.querySelectorAll('.vg-btn').forEach(b => {
|
||
b.classList.toggle('active', Number(b.textContent) === num);
|
||
});
|
||
renderVariant(num);
|
||
// Persist last opened variant
|
||
try { localStorage.setItem('exam9_last_variant', String(num)); } catch {}
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
/* ── Assignment modal ───────────────────────────────────────────── */
|
||
|
||
async function loadTeacherClasses() {
|
||
if (teacherClasses) return teacherClasses;
|
||
try {
|
||
const list = await LS.api('/api/classes');
|
||
teacherClasses = Array.isArray(list) ? list : [];
|
||
} catch {
|
||
teacherClasses = [];
|
||
}
|
||
return teacherClasses;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||
}
|
||
|
||
async function openAssignModal(variantNum) {
|
||
if (!variantTests[variantNum]) return;
|
||
const testId = variantTests[variantNum];
|
||
|
||
// Build body with shared LS.modal helper
|
||
const classes = await loadTeacherClasses();
|
||
const classesHtml = classes.length
|
||
? classes.map(c => `
|
||
<label class="ax-class">
|
||
<input type="checkbox" name="cls" value="${c.id}" />
|
||
<span class="ax-cname">${escapeHtml(c.name)}</span>
|
||
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
|
||
</label>`).join('')
|
||
: '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов. Создайте класс на странице «Классы».</div>';
|
||
|
||
const body = `
|
||
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault()">
|
||
<div class="ax-field">
|
||
<label>Классы</label>
|
||
<div class="ax-classes" id="ax-classes-list">${classesHtml}</div>
|
||
</div>
|
||
<div class="ax-field">
|
||
<label>Срок сдачи (опционально)</label>
|
||
<input type="datetime-local" class="ax-input" id="ax-deadline" />
|
||
</div>
|
||
</form>`;
|
||
|
||
const m = LS.modal({
|
||
title: `Назначить «Вариант ${variantNum}» как ДЗ`,
|
||
content: body,
|
||
size: 'sm',
|
||
actions: [
|
||
{ label: 'Отмена', onClick: () => m.close() },
|
||
{
|
||
label: 'Назначить', primary: true,
|
||
onClick: async () => {
|
||
const checked = Array.from(m.body.querySelectorAll('input[name="cls"]:checked')).map(el => Number(el.value));
|
||
if (!checked.length) { m.setError('Выберите хотя бы один класс'); return; }
|
||
|
||
const btns = m.root.querySelectorAll('.ls-mod-btn');
|
||
btns.forEach(b => b.disabled = true);
|
||
btns[1].textContent = 'Назначаю…';
|
||
|
||
try {
|
||
const r = await LS.api('/api/assignments/bulk', {
|
||
method: 'POST',
|
||
body: {
|
||
title: `Экзамен 9 — Вариант ${variantNum}`,
|
||
class_ids: checked,
|
||
mode: 'exam', count: 10,
|
||
test_id: testId,
|
||
deadline: m.body.querySelector('#ax-deadline').value || null,
|
||
is_homework: 1,
|
||
},
|
||
});
|
||
LS.toast(`Назначено в ${r.count || checked.length} класс(ах)`, 'success');
|
||
m.close();
|
||
} catch (e) {
|
||
m.setError(e.message || 'Не удалось создать задание');
|
||
btns.forEach(b => b.disabled = false);
|
||
btns[1].textContent = 'Назначить';
|
||
}
|
||
},
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
/* ── Boot ───────────────────────────────────────────────────────── */
|
||
(async function boot() {
|
||
const keys = Object.keys(VARIANTS);
|
||
if (!keys.length) {
|
||
document.getElementById('ex-main').innerHTML = '<div class="ex-empty">Варианты не загружены</div>';
|
||
return;
|
||
}
|
||
|
||
// Load user role + variant-to-test map (parallel)
|
||
const user = (typeof LS !== 'undefined') ? LS.getUser?.() : null;
|
||
userRole = user?.role || null;
|
||
|
||
if (userRole === 'teacher' || userRole === 'admin') {
|
||
try {
|
||
const r = await LS.api('/api/exam9/variants');
|
||
variantTests = r.variants || {};
|
||
} catch { variantTests = {}; }
|
||
}
|
||
|
||
// Resume last opened variant or open first one
|
||
let initial = Number(keys[0]);
|
||
try {
|
||
const last = Number(localStorage.getItem('exam9_last_variant'));
|
||
if (last && VARIANTS[last]) initial = last;
|
||
} catch {}
|
||
selectVariant(initial);
|
||
})();
|