feat: LS.modal — общий компонент модалок + миграция /exam9 + /my-students

Новый общий компонент 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
по мере касания этих страниц. Шаблон установлен.
This commit is contained in:
Maxim Dolgolyov
2026-05-16 18:41:27 +03:00
parent b1e645157a
commit bc22715734
4 changed files with 211 additions and 135 deletions
-53
View File
@@ -225,32 +225,6 @@
font-family:'Manrope',sans-serif; font-size:.9rem;
}
.ax-input:focus { outline:none; border-color:var(--violet); }
.ax-actions {
display:flex; gap:10px; justify-content:flex-end; margin-top:6px;
}
.ax-btn {
padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h);
background:transparent; color:var(--text);
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
cursor:pointer; transition:all .15s;
}
.ax-btn:hover { border-color:var(--text-2); }
.ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; }
.ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; }
.ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; }
.ax-error {
padding:9px 12px; border-radius:8px; background:rgba(241,91,68,.1);
border:1px solid rgba(241,91,68,.3); color:#F94144;
font-size:.84rem; display:none;
}
.ax-error.visible { display:block; }
.ax-success {
padding:9px 12px; border-radius:8px; background:rgba(6,214,160,.1);
border:1px solid rgba(6,214,160,.3); color:#06D6A0;
font-size:.84rem; display:none;
}
.ax-success.visible { display:block; }
@media (max-width: 600px) {
.ex-wrap { padding:20px 16px 60px; }
.ex-title { font-size:1.15rem; }
@@ -311,33 +285,6 @@
</div>
</div>
<div class="ex-overlay" id="assign-overlay" onclick="onAssignOverlayClick(event)">
<div class="ex-panel" onclick="event.stopPropagation()" style="width:min(520px,94vw)">
<div class="ex-panel-head">
<h2 id="assign-title">Назначить вариант</h2>
<button class="ex-panel-close" onclick="closeAssignModal()" title="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
<div class="ax-field">
<label>Классы</label>
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
</div>
<div class="ax-field">
<label>Срок сдачи (опционально)</label>
<input type="datetime-local" class="ax-input" id="ax-deadline" />
</div>
<div class="ax-error" id="ax-error"></div>
<div class="ax-success" id="ax-success"></div>
<div class="ax-actions">
<button type="button" class="ax-btn" onclick="closeAssignModal()">Отмена</button>
<button type="submit" class="ax-btn ax-btn-primary" id="ax-submit">Назначить</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
+61 -80
View File
@@ -176,7 +176,6 @@ function selectVariant(num) {
}
/* ── Assignment modal ───────────────────────────────────────────── */
let assignVariantNum = null;
async function loadTeacherClasses() {
if (teacherClasses) return teacherClasses;
@@ -189,94 +188,76 @@ async function loadTeacherClasses() {
return teacherClasses;
}
async function openAssignModal(variantNum) {
if (!variantTests[variantNum]) return;
assignVariantNum = variantNum;
document.getElementById('assign-title').textContent = `Назначить «Вариант ${variantNum}» как ДЗ`;
document.getElementById('ax-error').classList.remove('visible');
document.getElementById('ax-success').classList.remove('visible');
document.getElementById('ax-deadline').value = '';
document.getElementById('ax-submit').disabled = false;
document.getElementById('ax-submit').textContent = 'Назначить';
const listEl = document.getElementById('ax-classes-list');
listEl.textContent = 'Загрузка…';
const classes = await loadTeacherClasses();
if (!classes.length) {
listEl.innerHTML = '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов. Создайте класс на странице «Классы».</div>';
} else {
listEl.innerHTML = 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('');
}
document.getElementById('assign-overlay').classList.add('visible');
document.addEventListener('keydown', onAssignEsc);
}
function closeAssignModal() {
document.getElementById('assign-overlay').classList.remove('visible');
document.removeEventListener('keydown', onAssignEsc);
assignVariantNum = null;
}
function onAssignOverlayClick(e) {
if (e.target === document.getElementById('assign-overlay')) closeAssignModal();
}
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
async function submitAssign() {
const errorEl = document.getElementById('ax-error');
const successEl = document.getElementById('ax-success');
const submitBtn = document.getElementById('ax-submit');
errorEl.classList.remove('visible');
successEl.classList.remove('visible');
async function openAssignModal(variantNum) {
if (!variantTests[variantNum]) return;
const testId = variantTests[variantNum];
const checked = Array.from(document.querySelectorAll('#ax-classes-list input[name="cls"]:checked'))
.map(el => Number(el.value));
if (!checked.length) {
errorEl.textContent = 'Выберите хотя бы один класс';
errorEl.classList.add('visible');
return;
}
// 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 testId = variantTests[assignVariantNum];
const deadline = document.getElementById('ax-deadline').value || null;
if (!testId) { errorEl.textContent = 'Вариант не в банке вопросов'; errorEl.classList.add('visible'); return; }
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>`;
submitBtn.disabled = true;
submitBtn.textContent = 'Назначаю…';
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; }
try {
const r = await LS.api('/api/assignments/bulk', {
method: 'POST',
body: {
title: `Экзамен 9 — Вариант ${assignVariantNum}`,
class_ids: checked,
mode: 'exam',
count: 10,
test_id: testId,
deadline: deadline,
is_homework: 1,
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 = 'Назначить';
}
},
},
});
successEl.textContent = `Назначено в ${r.count || checked.length} классе(ах)`;
successEl.classList.add('visible');
submitBtn.textContent = 'Готово';
setTimeout(closeAssignModal, 1500);
} catch (e) {
errorEl.textContent = e.message || 'Не удалось создать задание';
errorEl.classList.add('visible');
submitBtn.disabled = false;
submitBtn.textContent = 'Назначить';
}
],
});
}
/* ── Boot ───────────────────────────────────────────────────────── */
+7 -2
View File
@@ -277,13 +277,18 @@
};
window.removeStudent = async function (id, name) {
if (!confirm(`Убрать «${name}» из списка «Мои ученики»?\n\nСозданные задания не удалятся.`)) return;
const ok = await LS.confirm(
`Убрать «${name}» из списка?\nСозданные задания не удалятся — ученик продолжит их видеть.`,
{ title: 'Убрать ученика', confirmText: 'Убрать', danger: true }
);
if (!ok) return;
try {
await LS.api('/api/teacher-students/' + id, { method: 'DELETE' });
students = students.filter(s => s.id !== id);
render();
LS.toast(`«${name}» убран из списка`, 'success');
} catch (e) {
alert('Ошибка: ' + e.message);
LS.toast('Ошибка: ' + e.message, 'error');
}
};